#![expect(unused, reason = "extraction; PHASE-03 prunes")]
#![expect(
clippy::fn_params_excessive_bools,
reason = "classifier gathers multiple facts; refactoring out of scope for SL-116"
)]
use super::allowlist::{
Allowlist, allowlist_violations, is_withheld, parse_allowlist, select_copies,
};
use super::jail::is_privileged_agent_type;
use super::marker::{DISPATCH_WORKER_AGENT_TYPE, marker_present, write_marker};
use super::provision::run_provision;
use super::shared::{
gather_fork_worktree, gather_tree_clean, is_linked_worktree, matches, project_anchor,
resolve_commit, resolve_common_dir, target_dir_for_branch,
};
use crate::fsutil::{self, CopyOutcome};
use crate::git;
use crate::root;
use anyhow::{Context, bail};
use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Stamp {
Ok,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StampRefusal {
MissingCwd,
BadDir,
MissingAgentType,
AlreadyMarked,
}
impl StampRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
StampRefusal::MissingCwd => "missing-cwd",
StampRefusal::BadDir => "bad-dir",
StampRefusal::MissingAgentType => "missing-agent-type",
StampRefusal::AlreadyMarked => "already-marked",
}
}
}
pub(crate) fn classify_stamp(
agent_type: &str,
cwd_present: bool,
cwd_is_under_repo_linked_worktree: bool,
already_marked: bool,
) -> Result<Stamp, StampRefusal> {
if !cwd_present {
return Err(StampRefusal::MissingCwd);
}
if !cwd_is_under_repo_linked_worktree {
return Err(StampRefusal::BadDir);
}
if agent_type != DISPATCH_WORKER_AGENT_TYPE {
return Err(StampRefusal::MissingAgentType);
}
if already_marked {
return Err(StampRefusal::AlreadyMarked);
}
Ok(Stamp::Ok)
}
#[derive(Debug, Default, serde::Deserialize)]
struct SubagentPayload {
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
agent_type: Option<String>,
}
fn cwd_shares_repo(repo: &Path, cwd: &Path) -> bool {
let repo_common = git::git_text(repo, &["rev-parse", "--git-common-dir"])
.ok()
.and_then(|c| resolve_common_dir(repo, &c).ok());
let cwd_common = git::git_text(cwd, &["rev-parse", "--git-common-dir"])
.ok()
.and_then(|c| resolve_common_dir(cwd, &c).ok());
match (repo_common, cwd_common) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}
pub(crate) fn run_stamp_subagent(path: Option<PathBuf>) -> anyhow::Result<()> {
let mut raw = String::new();
io::Read::read_to_string(&mut io::stdin(), &mut raw).context("read SubagentStart payload")?;
let payload: SubagentPayload = serde_json::from_str(&raw).unwrap_or_default();
let agent_type = payload.agent_type.unwrap_or_default();
let cwd_str = payload.cwd.unwrap_or_default();
let cwd_present = !cwd_str.is_empty();
let repo = root::find(path, &root::default_markers())
.ok()
.and_then(|r| fs::canonicalize(&r).ok());
let cwd_canon = if cwd_present {
fs::canonicalize(&cwd_str).ok()
} else {
None
};
let cwd_valid = match (repo.as_deref(), cwd_canon.as_deref()) {
(Some(repo), Some(cwd)) => {
is_linked_worktree(cwd).unwrap_or(false) && cwd_shares_repo(repo, cwd)
}
_ => false,
};
let already_marked = cwd_canon.as_deref().is_some_and(marker_present);
match classify_stamp(&agent_type, cwd_present, cwd_valid, already_marked) {
Ok(Stamp::Ok) => {}
Err(refusal) => {
writeln!(io::stderr(), "stamp-refused: {}", refusal.token())?;
bail!("stamp-refused: {}", refusal.token());
}
}
let (Some(_anchor), Some(cwd)) = (repo, cwd_canon) else {
let token = StampRefusal::BadDir.token();
writeln!(io::stderr(), "stamp-refused: {token}")?;
bail!("stamp-refused: {token}");
};
let act = git::primary_worktree(&cwd)
.and_then(|source| run_provision(Some(source), &cwd))
.and_then(|()| write_marker(&cwd));
if let Err(cause) = act {
writeln!(
io::stderr(),
"STAMP FAILED for {} — worktree LEFT in place (not removed); orchestrator post-spawn check will catch the unstamped worker: {cause:#}",
cwd.display()
)?;
return Err(cause.context(format!("stamp worker worktree {}", cwd.display())));
}
writeln!(io::stderr(), "stamped worker worktree {}", cwd.display())?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WorkerVerify {
Ok,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WorkerVerifyRefusal {
NoWorkerHead,
NotIsolated,
Unstamped,
WrongBase,
BranchMismatch,
}
impl WorkerVerifyRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
WorkerVerifyRefusal::NoWorkerHead => "no-worker-head",
WorkerVerifyRefusal::NotIsolated => "not-isolated",
WorkerVerifyRefusal::Unstamped => "unstamped",
WorkerVerifyRefusal::WrongBase => "wrong-base",
WorkerVerifyRefusal::BranchMismatch => "branch-mismatch",
}
}
}
pub(crate) fn classify_worker_verify(
head_resolved: bool,
is_isolated: bool,
marker_present: bool,
base_is_ancestor: bool,
head_is_branch_tip: bool,
) -> Result<WorkerVerify, WorkerVerifyRefusal> {
if !head_resolved {
return Err(WorkerVerifyRefusal::NoWorkerHead);
}
if !is_isolated {
return Err(WorkerVerifyRefusal::NotIsolated);
}
if !marker_present {
return Err(WorkerVerifyRefusal::Unstamped);
}
if !base_is_ancestor {
return Err(WorkerVerifyRefusal::WrongBase);
}
if !head_is_branch_tip {
return Err(WorkerVerifyRefusal::BranchMismatch);
}
Ok(WorkerVerify::Ok)
}
pub(crate) fn run_verify_worker(
base: &str,
dir: &Path,
branch: Option<&str>,
) -> anyhow::Result<()> {
let head_resolved = git::git_opt(dir, &["rev-parse", "--verify", "HEAD"])?.is_some();
let is_isolated = head_resolved && is_linked_worktree(dir)?;
let marker = marker_present(dir);
let base_is_ancestor = git::git_status_ok(dir, &["merge-base", "--is-ancestor", base, "HEAD"])?;
let head_is_branch_tip = match branch {
Some(s) => {
let head = git::git_opt(dir, &["rev-parse", "--verify", "HEAD"])?;
let tip = git::git_opt(dir, &["rev-parse", "--verify", &format!("{s}^{{commit}}")])?;
matches!((head, tip), (Some(h), Some(t)) if h == t)
}
None => true,
};
match classify_worker_verify(
head_resolved,
is_isolated,
marker,
base_is_ancestor,
head_is_branch_tip,
) {
Ok(WorkerVerify::Ok) => {
writeln!(
io::stderr(),
"verify-worker: base==B holds for {}",
dir.display()
)?;
Ok(())
}
Err(refusal) => {
writeln!(
io::stderr(),
"verify-worker-refused: {} ({})",
refusal.token(),
dir.display()
)?;
bail!("verify-worker-refused: {}", refusal.token());
}
}
}
const NOMINATION_ALLOWLIST_REL: &str = ".doctrine/state/orch-allowlist.txt";
fn allowlist_path(project_dir: &Path) -> PathBuf {
project_dir.join(NOMINATION_ALLOWLIST_REL)
}
fn read_allowlist(project_dir: &Path) -> Vec<String> {
fs::read_to_string(allowlist_path(project_dir))
.ok()
.map(|body| {
body.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn write_allowlist(project_dir: &Path, lines: &[String]) -> anyhow::Result<()> {
let path = allowlist_path(project_dir);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.with_context(|| format!("create nomination-allowlist dir {}", dir.display()))?;
}
let mut body = String::new();
for line in lines {
body.push_str(line);
body.push('\n');
}
#[expect(
clippy::disallowed_methods,
reason = "runtime nomination-allowlist state"
)]
fs::write(&path, body)
.with_context(|| format!("write nomination allowlist {}", path.display()))?;
Ok(())
}
fn is_nominated_among(existing: &[String], agent_id: &str) -> bool {
existing.iter().any(|line| line == agent_id)
}
pub(crate) fn append_nomination(existing: &[String], agent_id: &str) -> Vec<String> {
if is_nominated_among(existing, agent_id) {
existing.to_vec()
} else {
let mut out = existing.to_vec();
out.push(agent_id.to_string());
out
}
}
pub(crate) fn remove_nomination(existing: &[String], agent_id: &str) -> Vec<String> {
existing
.iter()
.filter(|line| line.as_str() != agent_id)
.cloned()
.collect()
}
pub(crate) fn is_nominated(project_dir: &Path, agent_id: &str) -> bool {
is_nominated_among(&read_allowlist(project_dir), agent_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NominateRefusal {
MissingProjectDir,
Ineligible,
MissingAgentId,
}
impl NominateRefusal {
pub(crate) fn token(self) -> &'static str {
match self {
NominateRefusal::MissingProjectDir => "missing-project-dir",
NominateRefusal::Ineligible => "ineligible",
NominateRefusal::MissingAgentId => "missing-agent-id",
}
}
}
pub(crate) fn classify_nominate(
agent_type: &str,
project_dir_present: bool,
agent_id_present: bool,
) -> Result<(), NominateRefusal> {
if !project_dir_present {
return Err(NominateRefusal::MissingProjectDir);
}
if !is_privileged_agent_type(agent_type) {
return Err(NominateRefusal::Ineligible);
}
if !agent_id_present {
return Err(NominateRefusal::MissingAgentId);
}
Ok(())
}
#[derive(Debug, Default, serde::Deserialize)]
struct NominatePayload {
#[serde(default)]
agent_id: Option<String>,
#[serde(default)]
agent_type: Option<String>,
}
pub(crate) fn act_nominate(
project_dir: Option<&Path>,
agent_id: Option<&str>,
agent_type: &str,
) -> anyhow::Result<()> {
let agent_id = agent_id.filter(|s| !s.is_empty());
if let Err(refusal) = classify_nominate(agent_type, project_dir.is_some(), agent_id.is_some()) {
writeln!(io::stderr(), "nominate-refused: {}", refusal.token())?;
bail!("nominate-refused: {}", refusal.token());
}
let (Some(project_dir), Some(agent_id)) = (project_dir, agent_id) else {
bail!("nominate: internal error — classify_nominate(Ok) without both inputs present");
};
let existing = read_allowlist(project_dir);
let updated = append_nomination(&existing, agent_id);
write_allowlist(project_dir, &updated)?;
writeln!(io::stderr(), "nominated {agent_id}")?;
Ok(())
}
pub(crate) fn run_nominate() -> anyhow::Result<()> {
let mut raw = String::new();
io::Read::read_to_string(&mut io::stdin(), &mut raw)
.context("read SubagentStart nominate payload")?;
let payload: NominatePayload = serde_json::from_str(&raw).unwrap_or_default();
let agent_type = payload.agent_type.unwrap_or_default();
act_nominate(
project_anchor().as_deref(),
payload.agent_id.as_deref(),
&agent_type,
)
}
#[derive(Debug, Default, serde::Deserialize)]
struct DenominatePayload {
#[serde(default)]
agent_id: Option<String>,
}
pub(crate) fn act_denominate(
project_dir: Option<&Path>,
agent_id: Option<&str>,
) -> anyhow::Result<()> {
let Some(agent_id) = agent_id.filter(|s| !s.is_empty()) else {
return Ok(()); };
let Some(project_dir) = project_dir else {
return Ok(()); };
let existing = read_allowlist(project_dir);
if !is_nominated_among(&existing, agent_id) {
return Ok(()); }
let updated = remove_nomination(&existing, agent_id);
write_allowlist(project_dir, &updated)?;
writeln!(io::stderr(), "denominated {agent_id}")?;
Ok(())
}
pub(crate) fn run_denominate() -> anyhow::Result<()> {
let mut raw = String::new();
io::Read::read_to_string(&mut io::stdin(), &mut raw)
.context("read SubagentStop denominate payload")?;
let payload: DenominatePayload = serde_json::from_str(&raw).unwrap_or_default();
act_denominate(project_anchor().as_deref(), payload.agent_id.as_deref())
}