use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use shepherd_cli::shepherd::{
Harness, RunState,
dispatch::{ProjectId, Role},
};
use shepherd_cli::{
BindRootDispatchRequest, DispatchService, DispatchStore, ResolveDispatchRequest,
};
fn fixture(label: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let path = std::env::temp_dir().join(format!("shepherd-dispatch-service-{label}-{suffix:x}"));
fs::create_dir_all(&path).expect("fixture");
fs::canonicalize(path).expect("canonical fixture")
}
fn service(root: &PathBuf) -> DispatchService {
let runs = root.join(".shepherd/runs");
let state: RunState = serde_json::from_value(serde_json::json!({
"run": "v645",
"status": "executing",
}))
.expect("state");
state.store(&runs.join("v645/run.json")).expect("run state");
fs::write(
root.join(".shepherd/project.json"),
br#"{"id":"018f47ce-72d7-7f64-9eb1-2f651d521c2a"}"#,
)
.expect("project identity");
DispatchService::with_project_root(
DispatchStore::new(runs),
ProjectId::new("018f47ce-72d7-7f64-9eb1-2f651d521c2a").expect("project"),
root,
)
}
#[test]
fn root_bootstrap_is_the_only_direct_lifecycle_entry() {
let root = fixture("root");
let service = service(&root);
let binding = service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Pi,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
1_000,
)
.expect("root bind");
assert_eq!(binding.role, Role::Shepherd);
let resolved = service
.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Pi,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: None,
tool_name: None,
tool_input: None,
},
2_000,
)
.expect("root resolve");
assert_eq!(resolved.role, Role::Shepherd);
let shell = service
.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Pi,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: Some("root-shell".into()),
tool_name: Some("Bash".into()),
tool_input: Some(serde_json::json!({"command": "printf safe"})),
},
2_000,
)
.expect("the exact native root may use the host shell without shell-text inference");
assert!(shell.agent_id.is_none());
assert!(shell.lane.is_none());
assert!(shell.write_paths.is_empty());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn root_bootstrap_does_not_publish_child_authority() {
let root = fixture("no-child");
let service = service(&root);
service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::ClaudeCode,
session_id: "root-session".into(),
role_carrier: "shepherd:shepherd".into(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
1_000,
)
.expect("root bind");
assert!(
!root
.join(".shepherd/runs/v645/dispatch/child.json")
.exists()
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn root_resolution_prefers_the_newest_binding_and_rejects_stale_explicit_authority() {
let root = fixture("explicit-root-resolution");
let service = service(&root);
let runs = root.join(".shepherd/runs");
fs::create_dir_all(runs.join("v657/dispatch")).expect("v657 dispatch namespace");
fs::write(
runs.join("v657/run.json"),
br#"{"run":"v657","status":"planted"}"#,
)
.expect("v657 planted state");
fs::create_dir_all(runs.join("v646/dispatch")).expect("corrupt sibling namespace");
fs::write(runs.join("v646/run.json"), b"{").expect("corrupt sibling state");
service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
1_000,
)
.expect("old executing binding");
service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v657".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Planting,
lease_ms: 60_000,
},
2_000,
)
.expect("new planted planning binding");
let resolved = service
.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: None,
harness: Harness::Codex,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: Some("tool-a".into()),
tool_name: Some("Write".into()),
tool_input: Some(serde_json::json!({"file_path": "plan.md"})),
},
3_000,
)
.expect("newest explicit root binding resolves without reading corrupt siblings");
assert_eq!(resolved.run.as_str(), "v657");
assert_eq!(
resolved.mode,
Some(shepherd_cli::shepherd::dispatch::RootMode::Planting)
);
let explicit_old = service
.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Codex,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: None,
tool_name: None,
tool_input: None,
},
3_000,
)
.expect_err("an explicit old-run envelope cannot replay superseded authority");
assert!(
explicit_old
.to_string()
.contains("root session authority moved from run `v645` to run `v657`"),
"{explicit_old}"
);
let unbound_explicit = service.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v646".into()),
harness: Harness::Codex,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: None,
tool_name: None,
tool_input: None,
},
3_000,
);
assert!(
unbound_explicit.is_err(),
"an explicit run with no matching binding must not fall back to the newest sibling"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn planning_binding_rebinds_to_execution_only_after_the_run_opens() {
let root = fixture("planning-execution-rebind");
let service = service(&root);
let run = root.join(".shepherd/runs/v657");
fs::create_dir_all(run.join("dispatch")).expect("v657 dispatch namespace");
fs::write(
run.join("run.json"),
br#"{"run":"v657","status":"planned"}"#,
)
.expect("planned state");
service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v657".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Planting,
lease_ms: 60_000,
},
1_000,
)
.expect("planning binding");
fs::write(
run.join("run.json"),
br#"{"run":"v657","status":"executing"}"#,
)
.expect("opened state");
let rebound = service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v657".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
2_000,
)
.expect("same trusted session must rebind after sprint open");
assert_eq!(
rebound.mode,
shepherd_cli::shepherd::dispatch::RootMode::Execution
);
assert_eq!(rebound.bound_at, 2_000);
let resolved = service
.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v657".into()),
harness: Harness::Codex,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: None,
tool_name: None,
tool_input: None,
},
3_000,
)
.expect("execution binding resolves");
assert_eq!(
resolved.mode,
Some(shepherd_cli::shepherd::dispatch::RootMode::Execution)
);
let downgrade = service.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v657".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Planting,
lease_ms: 60_000,
},
4_000,
);
assert!(
downgrade.is_err(),
"an execution binding must never downgrade to planning"
);
assert_eq!(
service
.store()
.load_root_binding_for_run(
&shepherd_cli::shepherd::dispatch::RunId::new("v657").expect("run"),
&shepherd_cli::shepherd::dispatch::SessionId::new("root-session").expect("session"),
)
.expect("durable execution binding")
.mode,
shepherd_cli::shepherd::dispatch::RootMode::Execution
);
fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn explicit_root_resolution_ignores_a_symlinked_sibling_run() {
use std::os::unix::fs::symlink;
let root = fixture("explicit-run-symlink-sibling");
let service = service(&root);
service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
1_000,
)
.expect("v645 binding");
let outside = root.join("outside-run");
fs::create_dir_all(outside.join("dispatch")).expect("outside sibling target");
symlink(&outside, root.join(".shepherd/runs/v646")).expect("symlinked sibling run");
let resolved = service
.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Codex,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: None,
tool_name: None,
tool_input: None,
},
2_000,
)
.expect("explicit v645 resolution must never inspect v646");
assert_eq!(resolved.run.as_str(), "v645");
fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn explicit_root_resolution_ignores_an_unreadable_sibling_dispatch_directory() {
use std::os::unix::fs::PermissionsExt;
let root = fixture("explicit-run-io-sibling");
let service = service(&root);
service
.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Codex,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
1_000,
)
.expect("v645 binding");
let sibling_dispatch = root.join(".shepherd/runs/v646/dispatch");
fs::create_dir_all(&sibling_dispatch).expect("sibling dispatch directory");
fs::set_permissions(&sibling_dispatch, fs::Permissions::from_mode(0o000))
.expect("make sibling unreadable");
let result = service.resolve(
ResolveDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v645".into()),
harness: Harness::Codex,
agent_id: None,
agent_type: None,
role_carrier: None,
lane: None,
session_id: "root-session".into(),
tool_call_id: None,
tool_name: None,
tool_input: None,
},
2_000,
);
fs::set_permissions(&sibling_dispatch, fs::Permissions::from_mode(0o700))
.expect("restore sibling permissions");
let resolved = result.expect("explicit v645 resolution must never inspect v646");
assert_eq!(resolved.run.as_str(), "v645");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn planted_run_rejects_execution_root_mode() {
let root = fixture("planted-execution-mode");
let service = service(&root);
let run = root.join(".shepherd/runs/v657");
fs::create_dir_all(run.join("dispatch")).expect("v657 dispatch namespace");
fs::write(
run.join("run.json"),
br#"{"run":"v657","status":"planted"}"#,
)
.expect("v657 planted state");
let result = service.bind_root(
BindRootDispatchRequest {
schema: "shepherd.dispatch-request/1".into(),
run: Some("v657".into()),
harness: Harness::ClaudeCode,
session_id: "root-session".into(),
role_carrier: Role::Shepherd.carrier(),
mode: shepherd_cli::shepherd::dispatch::RootMode::Execution,
lease_ms: 60_000,
},
1_000,
);
assert!(
result.is_err(),
"a planted run must never grant execution-mode root authority"
);
assert!(
!run.join("dispatch/.root-session.root-session.json")
.exists(),
"a rejected execution binding must not reach disk"
);
fs::remove_dir_all(root).expect("cleanup");
}