use std::path::{Path, PathBuf};
use serde_json::Value;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::dispatch_isolation::{
dispatch_agent, dispatch_isolation, shares_the_callers_tree,
};
use crate::commands::hook_payload::build_hook_payload;
fn deny_reason(agent: &str, cwd: &Path, live: &[String]) -> String {
let mut names: Vec<&str> = live.iter().map(String::as_str).collect();
names.sort_unstable();
names.dedup();
let running = names.join(", ");
format!(
"Concurrent shared-worktree dispatch denied (#4480): {running} is already running in \
{} without a worktree of its own, and this {agent} dispatch would put a second \
file-mutating agent on the same git HEAD. Git does not catch this — a `git checkout -b` \
refuses only when a tracked file differs between both branches AND has an uncommitted \
change, so untracked files and edits the two branches agree on transfer onto the wrong \
branch silently, with no error at any step. Re-dispatch this agent with \
`isolation: \"worktree\"` so it gets its own tree. If isolation is unavailable here, \
serialize instead: dispatch one file-mutating agent at a time from now on. Do not \
hand-roll a `git worktree add` in the prompt — this guard reads the declared \
isolation parameter, never the prompt, so a self-made worktree still counts as \
sharing this HEAD (#5649).",
cwd.display()
)
}
pub(crate) fn evaluate_shared_tree_dispatch(
tool_name: &str,
tool_input: Option<&Value>,
cwd: &Path,
live: &[String],
) -> Option<String> {
if live.is_empty() || !dispatch_shares_the_tree(tool_name, tool_input) {
return None;
}
let agent = dispatch_agent(tool_input).unwrap_or("this");
Some(deny_reason(agent, cwd, live))
}
pub(crate) fn dispatch_shares_the_tree(tool_name: &str, tool_input: Option<&Value>) -> bool {
is_subagent_dispatch_tool(tool_name)
&& dispatch_agent(tool_input)
.is_some_and(|agent| shares_the_callers_tree(agent, dispatch_isolation(tool_input)))
}
pub(crate) fn dispatch_cwd(payload: &Value) -> Option<PathBuf> {
resolve_dispatch_cwd(std::env::current_dir().ok(), payload)
}
pub(crate) fn resolve_dispatch_cwd(
process_cwd: Option<PathBuf>,
payload: &Value,
) -> Option<PathBuf> {
process_cwd.or_else(|| {
payload
.get("cwd")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
})
}
fn project_dispatch_input(forwarded: &mut Value) {
const FORWARDED_INPUT_FIELDS: [&str; 3] = ["subagent_type", "isolation", "description"];
let Some(input) = forwarded.get("input").filter(|i| i.is_object()) else {
return;
};
let mut projected = serde_json::Map::new();
for key in FORWARDED_INPUT_FIELDS {
if let Some(value) = input.get(key) {
projected.insert(key.to_string(), value.clone());
}
}
forwarded["input"] = Value::Object(projected);
}
pub(crate) async fn claim_shared_tree(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> Vec<String> {
if session_id.is_empty() {
return Vec::new();
}
let Ok(client) = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(std::time::Duration::from_secs(2))
.build()
else {
return Vec::new();
};
let endpoint = format!("{url}/api/v1/sessions/{session_id}/delegations/shared-tree-dispatch");
let mut forwarded = build_hook_payload(&cwd.display().to_string(), Some(payload), None);
project_dispatch_input(&mut forwarded);
let body = serde_json::json!({ "payload": forwarded });
let Ok(response) = client.post(&endpoint).json(&body).send().await else {
return Vec::new();
};
let Ok(response) = response.error_for_status() else {
return Vec::new();
};
let Ok(body) = response.json::<Value>().await else {
return Vec::new();
};
let live: Vec<String> = body
.get("agents")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| row.get("agent").and_then(Value::as_str))
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
warn_on_eligibility_divergence(&body, &live);
live
}
fn warn_on_eligibility_divergence(body: &Value, live: &[String]) {
if !eligibility_diverged(body, live) {
return;
}
eprintln!(
"tm hook --pm-guard: the daemon reported no live writers but claimed nothing (#5324). \
The guard classified this dispatch as sharing the working tree and the daemon did not, \
so concurrent shared-worktree dispatch is NOT being enforced for this agent. The usual \
cause is a running daemon older than the `tm` on PATH, built before this agent was \
added to its bundled table — restart the daemon (`tm restart`) to clear it. Allowing \
the dispatch: this path fails open by design."
);
}
fn eligibility_diverged(body: &Value, live: &[String]) -> bool {
let claimed = body
.get("claimed")
.and_then(Value::as_bool)
.unwrap_or(false);
live.is_empty() && !claimed
}
pub(crate) async fn evaluate(
url: &str,
payload: &Value,
tool_name: &str,
tool_input: Option<&Value>,
session_id: &str,
) -> Option<String> {
evaluate_with_cwd(
url,
dispatch_cwd(payload),
payload,
tool_name,
tool_input,
session_id,
)
.await
}
pub(crate) async fn evaluate_with_cwd(
url: &str,
cwd: Option<PathBuf>,
payload: &Value,
tool_name: &str,
tool_input: Option<&Value>,
session_id: &str,
) -> Option<String> {
if !dispatch_shares_the_tree(tool_name, tool_input) {
return None;
}
let cwd = cwd?;
let live = claim_shared_tree(url, session_id, &cwd, payload).await;
evaluate_shared_tree_dispatch(tool_name, tool_input, &cwd, &live)
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm::core::agent::SUBAGENT_DISPATCH_TOOLS;
fn input(agent: &str, isolation: Option<&str>) -> Value {
match isolation {
Some(i) => serde_json::json!({"subagent_type": agent, "isolation": i}),
None => serde_json::json!({"subagent_type": agent}),
}
}
#[test]
fn denies_a_second_concurrent_unisolated_engineer() {
for tool in SUBAGENT_DISPATCH_TOOLS {
let reason = evaluate_shared_tree_dispatch(
tool,
Some(&input("rust-engineer", None)),
Path::new("/repo"),
&["python-engineer".to_string()],
)
.expect("a second unisolated engineer must be denied");
assert!(reason.contains("python-engineer"), "{reason}");
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
}
#[test]
fn deny_reason_offers_only_remedies_that_always_work() {
let reason = deny_reason(
"rust-engineer",
Path::new("/repo"),
&["python-engineer".to_string()],
);
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(
reason.contains("serialize"),
"the deny must offer the serialize fallback for when isolation is unavailable: \
{reason}"
);
for banned in ["wait for", "wait on", "wait until", "waiting for"] {
assert!(
!reason.contains(banned),
"the deny must not advise waiting on an agent that may never report \
(found {banned:?}): {reason}"
);
}
}
#[test]
fn allows_the_first_dispatch() {
assert_eq!(
evaluate_shared_tree_dispatch(
"Agent",
Some(&input("rust-engineer", None)),
Path::new("/repo"),
&[],
),
None
);
}
#[test]
fn allows_an_isolated_dispatch() {
for mode in ["worktree", "remote"] {
assert_eq!(
evaluate_shared_tree_dispatch(
"Agent",
Some(&input("rust-engineer", Some(mode))),
Path::new("/repo"),
&["python-engineer".to_string()],
),
None,
"isolation={mode} must be allowed"
);
}
}
#[test]
fn allows_a_read_only_agent() {
for agent in ["research", "code-critic", "code-analyzer"] {
assert_eq!(
evaluate_shared_tree_dispatch(
"Agent",
Some(&input(agent, None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
),
None,
"{agent} must be allowed alongside a running engineer"
);
}
}
#[test]
fn denies_a_file_writing_non_engineer_alongside_an_engineer() {
for agent in ["documentation", "version-control", "qa", "web-qa", "api-qa"] {
let reason = evaluate_shared_tree_dispatch(
"Agent",
Some(&input(agent, None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
)
.unwrap_or_else(|| panic!("{agent} writes files and must be denied"));
assert!(reason.contains("rust-engineer"), "{reason}");
}
}
#[test]
fn allows_when_the_agent_is_unknown() {
for agent in ["some-project-agent", ""] {
assert_eq!(
evaluate_shared_tree_dispatch(
"Agent",
Some(&input(agent, None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
),
None
);
}
assert_eq!(
evaluate_shared_tree_dispatch(
"Agent",
None,
Path::new("/repo"),
&["rust-engineer".to_string()]
),
None
);
}
#[test]
fn allows_every_non_dispatch_tool() {
for tool in [
"Read",
"Edit",
"Write",
"Bash",
"SendMessage",
"agent",
"task",
] {
assert_eq!(
evaluate_shared_tree_dispatch(
tool,
Some(&input("rust-engineer", None)),
Path::new("/repo"),
&["python-engineer".to_string()],
),
None,
"{tool} must never be denied by this guard"
);
}
}
#[test]
fn deny_reason_dedupes_concurrent_siblings() {
let reason = deny_reason(
"rust-engineer",
Path::new("/repo"),
&["rust-engineer".to_string(), "rust-engineer".to_string()],
);
assert_eq!(
reason.matches("rust-engineer is already").count(),
1,
"{reason}"
);
}
#[test]
fn resolve_dispatch_cwd_falls_back_to_the_payload() {
let payload = serde_json::json!({"cwd": "/from/payload"});
assert_eq!(
resolve_dispatch_cwd(None, &payload),
Some(PathBuf::from("/from/payload"))
);
assert_eq!(
resolve_dispatch_cwd(Some(PathBuf::from("/from/process")), &payload),
Some(PathBuf::from("/from/process"))
);
}
#[test]
fn resolve_dispatch_cwd_is_none_when_nothing_resolves() {
for payload in [
serde_json::json!({}),
serde_json::json!({"cwd": ""}),
serde_json::json!({"cwd": 7}),
] {
assert_eq!(resolve_dispatch_cwd(None, &payload), None, "{payload}");
}
}
fn spawn_denying_mock() -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
std::thread::spawn(move || {
if let Ok((mut socket, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf);
let body = r#"{"agents":[{"agent":"python-engineer","count":1}],"total":1}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes());
}
});
url
}
#[tokio::test]
async fn evaluate_allows_when_the_cwd_cannot_be_resolved() {
let url = spawn_denying_mock();
let verdict = evaluate_with_cwd(
&url,
None,
&serde_json::json!({}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await;
assert_eq!(
verdict, None,
"an unresolvable cwd must ALLOW even when the daemon would deny"
);
}
#[test]
fn dispatch_cwd_prefers_the_process_directory() {
let payload = serde_json::json!({"cwd": "/some/other/place"});
assert_eq!(dispatch_cwd(&payload), std::env::current_dir().ok());
}
#[tokio::test]
async fn claim_shared_tree_is_empty_when_the_daemon_is_unreachable() {
let live = claim_shared_tree(
"http://127.0.0.1:1",
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({"tool_use_id": "toolu_X"}),
)
.await;
assert!(live.is_empty());
}
#[tokio::test]
async fn claim_shared_tree_is_empty_without_a_session_id() {
let started = std::time::Instant::now();
let live = claim_shared_tree(
"http://127.0.0.1:1",
"",
Path::new("/repo"),
&serde_json::json!({}),
)
.await;
assert!(live.is_empty());
assert!(started.elapsed() < std::time::Duration::from_millis(400));
}
#[test]
fn claim_payload_carries_only_the_fields_the_daemon_reads() {
let mut forwarded = serde_json::json!({
"cwd": "/repo",
"tool": "Agent",
"input": {
"subagent_type": "rust-engineer",
"isolation": "worktree",
"description": "short label",
"prompt": "x".repeat(4096),
"extra": {"nested": true},
},
});
project_dispatch_input(&mut forwarded);
let input = forwarded.get("input").expect("input survives");
let keys: Vec<&String> = input
.as_object()
.expect("input stays an object")
.keys()
.collect();
assert_eq!(
keys,
vec!["description", "isolation", "subagent_type"],
"only the three fields the route and the tracker read may be sent"
);
assert_eq!(input["subagent_type"], "rust-engineer");
assert_eq!(input["isolation"], "worktree");
assert_eq!(input["description"], "short label");
assert_eq!(forwarded["tool"], "Agent");
assert_eq!(forwarded["cwd"], "/repo");
}
#[test]
fn claim_payload_projection_leaves_a_non_object_input_alone() {
let mut absent = serde_json::json!({"cwd": "/repo", "tool": "Agent"});
project_dispatch_input(&mut absent);
assert!(absent.get("input").is_none(), "absent input stays absent");
let mut scalar = serde_json::json!({"cwd": "/repo", "input": "not-an-object"});
project_dispatch_input(&mut scalar);
assert_eq!(scalar["input"], "not-an-object");
}
#[test]
fn eligibility_divergence_is_an_empty_answer_that_claimed_nothing() {
let empty: Vec<String> = Vec::new();
assert!(
eligibility_diverged(&serde_json::json!({"agents": [], "claimed": false}), &empty),
"empty answer that claimed nothing is divergence"
);
assert!(
eligibility_diverged(&serde_json::json!({"agents": []}), &empty),
"a daemon too old to send `claimed` reads as not-claimed"
);
assert!(
!eligibility_diverged(&serde_json::json!({"agents": [], "claimed": true}), &empty),
"the ordinary first dispatch claims, and is silent"
);
assert!(
!eligibility_diverged(
&serde_json::json!({"claimed": false}),
&["rust-engineer".to_string()]
),
"a non-empty answer is a deny, where claiming nothing is correct"
);
}
#[tokio::test]
async fn evaluate_never_calls_the_daemon_for_a_non_dispatch_tool() {
let payload = serde_json::json!({"cwd": "/repo"});
let started = std::time::Instant::now();
let verdict = evaluate(
"http://127.0.0.1:1",
&payload,
"Read",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await;
assert_eq!(verdict, None);
assert!(
started.elapsed() < std::time::Duration::from_millis(400),
"the daemon must not be dialled for a non-dispatch tool"
);
}
}