use std::path::{Path, PathBuf};
use serde_json::Value;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::dispatch_isolation::{
blocked_by_shared_tree, dispatch_agent, dispatch_isolation,
};
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 — possibly dispatched by a different session standing \
in the same directory (ADR-0048) — 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| blocked_by_shared_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,
) -> SharedTreeClaim {
claim_shared_tree_on(
url,
session_id,
cwd,
payload,
SHARED_TREE_ROUTE,
warn_on_eligibility_divergence,
)
.await
}
async fn claim_shared_tree_on(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
route: &str,
warn_on_answer: impl Fn(&Value, &[String]),
) -> SharedTreeClaim {
match post_shared_tree(url, session_id, cwd, payload, route).await {
SharedTreeReply::Answered(body) => {
let live = writers_in(&body);
warn_on_answer(&body, &live);
SharedTreeClaim::Writers(live)
}
SharedTreeReply::Unavailable(detail) => {
warn_guard_unavailable(&detail);
SharedTreeClaim::Writers(Vec::new())
}
SharedTreeReply::Unanswered(detail) => SharedTreeClaim::Unknown(detail),
}
}
pub(crate) enum SharedTreeClaim {
Writers(Vec<String>),
Unknown(String),
}
fn warn_guard_unavailable(detail: &str) {
eprintln!(
"tm hook --pm-guard: no daemon answered the shared-tree claim (#5923) — {detail}. \
Concurrent shared-worktree dispatch is NOT being enforced for this dispatch: a second \
file-mutating agent can join this git HEAD, and git reports nothing when it does. Start \
the daemon (`tm start`) to restore the guard. Allowing the dispatch: an absent daemon is \
a degraded mode this path accepts deliberately, unlike a daemon that is running and did \
not answer, which denies."
);
}
fn unanswered_deny_reason(agent: &str, cwd: &Path, detail: &str) -> String {
format!(
"Concurrent shared-worktree dispatch denied (#5923): the daemon did not answer this \
guard's claim for {} — {detail}. That answer is the only thing that can say whether \
another file-mutating agent is already writing in this directory, so admitting this \
{agent} dispatch would put a second agent on one git HEAD with the check that exists to \
stop it never having run. A timeout or an error from a running daemon is correlated with \
load, which is when a concurrent dispatch is most likely, so this denies rather than \
assuming the tree is free. Re-dispatch this agent with `isolation: \"worktree\"` — an \
isolated dispatch never needs this answer and is never blocked by it. If isolation is \
unavailable here, serialize instead: dispatch one file-mutating agent at a time. If the \
daemon is unhealthy, `tm doctor` reports it and `tm restart` clears it.",
cwd.display()
)
}
pub(crate) async fn live_shared_tree_writers(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> Vec<String> {
post_shared_tree(url, session_id, cwd, payload, SHARED_TREE_ROUTE)
.await
.answered()
.as_ref()
.map(writers_in)
.unwrap_or_default()
}
pub(crate) async fn live_shared_tree_writers_or_deny(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> Result<Vec<String>, String> {
match post_shared_tree(url, session_id, cwd, payload, SHARED_TREE_ROUTE).await {
SharedTreeReply::Answered(body) => Ok(writers_in(&body)),
SharedTreeReply::Unavailable(detail) | SharedTreeReply::Unanswered(detail) => Err(detail),
}
}
pub(crate) async fn live_shared_tree_writers_in(
url: &str,
session_id: &str,
dirs: &[&Path],
payload: &Value,
) -> Vec<String> {
let mut asked: Vec<&Path> = Vec::with_capacity(dirs.len());
for dir in dirs {
if asked.contains(dir) {
continue;
}
asked.push(dir);
let live = live_shared_tree_writers(url, session_id, dir, payload).await;
if !live.is_empty() {
return live;
}
}
Vec::new()
}
pub(crate) async fn evaluate_granted_worktree(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
tool_input: Option<&Value>,
updated_input: &Value,
) -> Option<String> {
let mut granted = payload.clone();
granted["tool_input"] = updated_input.clone();
let agent = dispatch_agent(tool_input).unwrap_or("this");
match claim_shared_tree_on(
url,
session_id,
cwd,
&granted,
GRANTED_WORKTREE_ROUTE,
|body, live| warn_on_unrecorded_grant(body, live, cwd),
)
.await
{
SharedTreeClaim::Writers(live) if live.is_empty() => None,
SharedTreeClaim::Writers(live) => Some(granted_deny_reason(agent, cwd, &live)),
SharedTreeClaim::Unknown(detail) => Some(unanswered_grant_deny_reason(agent, cwd, &detail)),
}
}
fn warn_on_unrecorded_grant(body: &Value, live: &[String], cwd: &Path) {
if !eligibility_diverged(body, live) {
return;
}
eprintln!(
"tm hook --pm-guard: granted a worktree in {} but the daemon recorded nothing (#5769). \
The dispatch's delegation record therefore still reads as unisolated, so this agent will \
be named as writing in this checkout and `git merge`/`git rebase` here will be denied \
until the record \
goes stale. The usual cause is a running daemon older than the `tm` on PATH, built \
before the granted-worktree route existed — restart the daemon (`tm restart`) to clear \
it. Granting anyway: this path fails open by design.",
cwd.display()
);
}
fn granted_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();
format!(
"Dispatch denied in a shared main checkout (ADR-0048): {} is a project's main checkout, \
and the daemon's delegation records name {} as running there with no worktree of its \
own — possibly dispatched by a different session standing in the same directory. This \
{agent} dispatch was granted a worktree of its own, but that grant is a rewrite of the \
dispatch's arguments and this guard cannot confirm the harness applied it; if it did \
not, a second file-mutating agent joins the same git HEAD, which is the reported \
failure — a commit landing on another workstream's branch, with no error at any step. \
Re-issue this dispatch with `isolation: \"worktree\"` declared explicitly, which needs \
no rewrite to be applied. If isolation is unavailable here, serialize instead: dispatch \
one file-mutating agent at a time. If you believe that record is stale — the agent \
finished without its stop signal reaching the daemon — say so rather than retrying, \
since nothing here can tell a finished agent from a running one.",
cwd.display(),
names.join(", ")
)
}
fn unanswered_grant_deny_reason(agent: &str, cwd: &Path, detail: &str) -> String {
format!(
"Dispatch denied in a shared main checkout (#5923): {} is a project's main checkout and \
the daemon did not answer this guard's claim for it — {detail}. That answer is the only \
thing that can say whether another file-mutating agent is already writing here, and \
this guard asks it BEFORE granting a worktree precisely because the grant is a rewrite \
of this dispatch's arguments that this binary cannot confirm the harness applies \
(ADR-0048). Granting on silence would admit this {agent} dispatch with that check never \
having run, and if the rewrite is not applied a second file-mutating agent joins the \
same git HEAD — a commit landing on another workstream's branch, with no error at any \
step. A timeout or an error from a running daemon is correlated with load, which is \
when a concurrent dispatch is most likely, so this denies rather than assuming the \
checkout is free. Re-issue this dispatch with `isolation: \"worktree\"` declared \
explicitly — it needs no rewrite and never reaches this question. If isolation is \
unavailable here, serialize instead: dispatch one file-mutating agent at a time. If the \
daemon is unhealthy, `tm doctor` reports it and `tm restart` clears it.",
cwd.display()
)
}
const SHARED_TREE_ROUTE: &str = "shared-tree-dispatch";
const GRANTED_WORKTREE_ROUTE: &str = "granted-worktree";
pub(crate) enum SharedTreeReply {
Answered(Value),
Unavailable(String),
Unanswered(String),
}
impl SharedTreeReply {
fn answered(self) -> Option<Value> {
match self {
Self::Answered(body) => Some(body),
Self::Unavailable(_) | Self::Unanswered(_) => None,
}
}
}
pub(crate) async fn post_shared_tree(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
route: &str,
) -> SharedTreeReply {
if session_id.is_empty() {
return SharedTreeReply::Unavailable(
"the hook payload carries no session id, so no session's delegations can be addressed"
.to_string(),
);
}
let client = match reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(std::time::Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(error) => {
return SharedTreeReply::Unavailable(format!(
"the guard's HTTP client could not be built: {error}"
));
}
};
let endpoint = format!("{url}/api/v1/sessions/{session_id}/delegations/{route}");
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 response = match client.post(&endpoint).json(&body).send().await {
Ok(response) => response,
Err(error) => return classify_transport_failure(&endpoint, &error),
};
let status = response.status();
let response = match response.error_for_status() {
Ok(response) => response,
Err(_) if status == reqwest::StatusCode::NOT_FOUND => {
return SharedTreeReply::Unavailable(format!(
"the daemon has no {route} route (404), so it is older than this `tm`"
));
}
Err(error) => {
return SharedTreeReply::Unanswered(format!("the daemon answered {status}: {error}"));
}
};
match response.json::<Value>().await {
Ok(body) => SharedTreeReply::Answered(body),
Err(error) => {
SharedTreeReply::Unanswered(format!("the daemon's answer did not parse: {error}"))
}
}
}
fn classify_transport_failure(endpoint: &str, error: &reqwest::Error) -> SharedTreeReply {
if error.is_connect() && !error.is_timeout() {
return SharedTreeReply::Unavailable(format!("nothing is listening at {endpoint}"));
}
SharedTreeReply::Unanswered(format!(
"the request to {endpoint} did not complete: {error}"
))
}
fn writers_in(body: &Value) -> 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()
}
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?;
match claim_shared_tree(url, session_id, &cwd, payload).await {
SharedTreeClaim::Writers(live) => {
evaluate_shared_tree_dispatch(tool_name, tool_input, &cwd, &live)
}
SharedTreeClaim::Unknown(detail) => Some(unanswered_deny_reason(
dispatch_agent(tool_input).unwrap_or("this"),
&cwd,
&detail,
)),
}
}
#[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 granted_deny_reason_does_not_offer_the_isolation_it_already_built() {
let reason = granted_deny_reason(
"rust-engineer",
Path::new("/repo/main"),
&["python-engineer".to_string(), "python-engineer".to_string()],
);
assert!(reason.contains("ADR-0048"), "{reason}");
assert!(reason.contains("/repo/main"), "{reason}");
assert_eq!(reason.matches("python-engineer").count(), 1, "{reason}");
assert!(
reason.contains("the daemon's delegation records name"),
"{reason}"
);
assert!(
reason.contains("cannot confirm the harness applied it"),
"{reason}"
);
assert!(reason.contains("declared explicitly"), "{reason}");
assert!(reason.contains("serialize"), "{reason}");
assert!(reason.contains("stale"), "{reason}");
for banned in ["wait for", "wait on", "wait until", "waiting for"] {
assert!(!reason.contains(banned), "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 allows_version_control_alongside_a_live_writer() {
for tool in SUBAGENT_DISPATCH_TOOLS {
assert_eq!(
evaluate_shared_tree_dispatch(
tool,
Some(&input("version-control", None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
),
None,
"{tool}: version-control must run in the tree it was given"
);
}
}
#[test]
fn denies_a_file_writing_non_engineer_alongside_an_engineer() {
for agent in ["documentation", "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());
}
fn writers_of(claim: SharedTreeClaim) -> Vec<String> {
match claim {
SharedTreeClaim::Writers(live) => live,
SharedTreeClaim::Unknown(detail) => {
panic!("expected an answered claim, got Unknown({detail})")
}
}
}
fn spawn_silent_mock() -> String {
use std::io::Read;
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);
std::thread::sleep(std::time::Duration::from_secs(10));
}
});
url
}
fn spawn_mock_answering(status_line: &'static str, body: &'static str) -> 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 response = format!(
"HTTP/1.1 {status_line}\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
}
async fn claim_against(url: &str) -> SharedTreeClaim {
claim_shared_tree(
url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({"tool_use_id": "toolu_X"}),
)
.await
}
#[tokio::test]
async fn claim_shared_tree_is_empty_when_the_daemon_is_unreachable() {
assert!(writers_of(claim_against("http://127.0.0.1:1").await).is_empty());
}
#[tokio::test]
async fn claim_shared_tree_is_empty_without_a_session_id() {
let started = std::time::Instant::now();
let claim = claim_shared_tree(
"http://127.0.0.1:1",
"",
Path::new("/repo"),
&serde_json::json!({}),
)
.await;
assert!(writers_of(claim).is_empty());
assert!(started.elapsed() < std::time::Duration::from_millis(400));
}
#[tokio::test]
async fn claim_is_empty_when_the_daemon_has_no_such_route() {
let url = spawn_mock_answering("404 Not Found", r#"{"error":"no such route"}"#);
assert!(writers_of(claim_against(&url).await).is_empty());
}
#[tokio::test]
async fn claim_is_unknown_when_the_daemon_times_out() {
let url = spawn_silent_mock();
let started = std::time::Instant::now();
let claim = claim_against(&url).await;
assert!(
matches!(claim, SharedTreeClaim::Unknown(_)),
"a daemon that never answered must not read as an empty tree"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"the claim must stay inside its own bounded budget"
);
}
#[tokio::test]
async fn claim_is_unknown_when_the_daemon_answers_500() {
let url = spawn_mock_answering("500 Internal Server Error", r#"{"error":"boom"}"#);
assert!(matches!(
claim_against(&url).await,
SharedTreeClaim::Unknown(_)
));
}
#[tokio::test]
async fn claim_is_unknown_when_the_body_does_not_parse() {
let url = spawn_mock_answering("200 OK", "not json at all");
assert!(matches!(
claim_against(&url).await,
SharedTreeClaim::Unknown(_)
));
}
#[tokio::test]
async fn claim_is_answered_when_the_body_parses_with_an_unexpected_shape() {
let url = spawn_mock_answering("200 OK", r#"{"unexpected":"shape"}"#);
assert!(writers_of(claim_against(&url).await).is_empty());
}
#[tokio::test]
async fn evaluate_denies_when_a_running_daemon_does_not_answer() {
let url = spawn_silent_mock();
let reason = evaluate_with_cwd(
&url,
Some(PathBuf::from("/repo")),
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await
.expect("an unanswered claim must deny");
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("isolation"), "{reason}");
}
#[tokio::test]
async fn evaluate_allows_when_no_daemon_is_listening() {
let verdict = evaluate_with_cwd(
"http://127.0.0.1:1",
Some(PathBuf::from("/repo")),
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await;
assert_eq!(verdict, None);
}
async fn granted_against(url: &str) -> Option<String> {
evaluate_granted_worktree(
url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({"tool_use_id": "toolu_X"}),
Some(&input("rust-engineer", None)),
&input("rust-engineer", Some("worktree")),
)
.await
}
#[tokio::test]
async fn granted_worktree_denies_when_a_running_daemon_does_not_answer() {
let url = spawn_silent_mock();
let started = std::time::Instant::now();
let reason = granted_against(&url)
.await
.expect("an unanswered grant must deny");
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("ADR-0048"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"the grant must stay inside its own bounded budget"
);
}
#[tokio::test]
async fn granted_worktree_grants_when_no_daemon_is_listening() {
assert_eq!(granted_against("http://127.0.0.1:1").await, None);
}
#[tokio::test]
async fn granted_worktree_denies_beside_a_live_writer() {
let url = spawn_denying_mock();
let reason = granted_against(&url)
.await
.expect("a live writer must deny the grant");
assert!(reason.contains("python-engineer"), "{reason}");
assert!(reason.contains("ADR-0048"), "{reason}");
}
#[test]
fn unanswered_grant_deny_reason_names_the_failure_and_the_rewrite() {
let reason = unanswered_grant_deny_reason(
"rust-engineer",
Path::new("/repo"),
"the daemon answered 500 Internal Server Error",
);
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains("500 Internal Server Error"), "{reason}");
assert!(reason.contains("ADR-0048"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(reason.contains("serialize"), "{reason}");
}
#[test]
fn unanswered_deny_reason_names_the_failure_and_offers_isolation() {
let reason = unanswered_deny_reason(
"rust-engineer",
Path::new("/repo"),
"the daemon answered 500 Internal Server Error",
);
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains("500 Internal Server Error"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(reason.contains("serialize"), "{reason}");
}
#[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"
);
}
}