mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
fix(agent): verify declared deliverables against owned spawn evidence
Persist declared file outputs and their per-path delivery verdicts in the existing worker receipt. Compare explicit edit declarations with the exact spawn HEAD and dirty-file content; exclude citations and read-only workers from edit accusations. Echo delivery verification before terminal publication, keep arbitrary code fail-closed beside shared writers, and explain actual write/write overlap remedies. Verification: cargo fmt --all -- --check and git diff --check passed. 11 focused regression tests authored, NOT RUN (0 executed in this packet). Combined Rust tests, clippy, npm test and npm run check:web remain pending root integration; this commit is not release qualification. SHA-6502/SHA-6504 source implementation; SHA-6503 remains open until its read-only execution acceptance is verified without weakening shell policy. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
b00afeb3af
commit
5a1cef906c
8 files changed
+953
-166
No files matched your search
@@ -467,6 +467,7 @@ pub fn fleet_task_to_worker_spec_with_profiles(
|
||||
writable_files: Vec::new(),
|
||||
coordination_contracts,
|
||||
expected_artifact: None,
|
||||
deliverables: Vec::new(),
|
||||
token_budget: task_spec
|
||||
.budget
|
||||
.as_ref()
|
||||
|
||||
@@ -2682,9 +2682,11 @@ async fn agent_runs_runtime_api_exposes_persisted_worker_receipts() -> Result<()
|
||||
note: "not reported".to_string(),
|
||||
},
|
||||
usage_source_fingerprints: Default::default(),
|
||||
delivery_evidence: Default::default(),
|
||||
verification: AgentRunVerificationSummary {
|
||||
status: "self_report_only".to_string(),
|
||||
summary: "no verified receipt attached".to_string(),
|
||||
deliverables: Vec::new(),
|
||||
},
|
||||
recommended_action: AgentRunRecommendedAction {
|
||||
action: "verify_self_report".to_string(),
|
||||
|
||||
@@ -584,12 +584,14 @@ impl CoordinationLedger {
|
||||
self.contentions.push(receipt);
|
||||
trim_front(&mut self.contentions, COORDINATION_RECORD_LIMIT);
|
||||
return Err(format!(
|
||||
"write-scope contention with {} (roots: {:?}, files: {:?}, contracts: {:?}); serialize the work, narrow the claim, use worktree isolation, or — if that owner has already settled — clear its claim with agent(action=\"release\", agent_id=\"{}\")",
|
||||
"write-scope contention with {}: requested roots {:?}, files {:?}, contracts {:?} overlap its writable roots {:?}, files {:?}, contracts {:?}. Claim disjoint sibling write_roots (for example tmp/scan/worker-a and tmp/scan/worker-b) or exact_files for each output. A read-only worker uses write_authority=read_only without a write claim. Otherwise serialize the writers or use worktree isolation; a nested path inside an existing writable root still overlaps.",
|
||||
existing.claim.owner,
|
||||
claim.roots,
|
||||
claim.exact_files,
|
||||
claim.contracts,
|
||||
existing.claim.roots,
|
||||
existing.claim.exact_files,
|
||||
existing.claim.contracts,
|
||||
existing.claim.owner
|
||||
existing.claim.contracts
|
||||
));
|
||||
}
|
||||
self.write_claims
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
//! Delivery evidence replacing the old prose-verb/git-status heuristic.
|
||||
//! The worker ledger retains the spawn baseline; this module only reads files.
|
||||
|
||||
use super::{AgentRunVerificationSummary, AgentWorkerSpec, normalize_claim_path};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
pub(super) const MAX_DELIVERABLES: usize = 16;
|
||||
const MAX_BASELINE_PATHS: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeliverableVerdict {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeliveryEvidence {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
baseline: Option<GitDeliveryBaseline>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub(super) observed_writes: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
pub(super) checked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct GitDeliveryBaseline {
|
||||
root: PathBuf,
|
||||
head: Option<String>,
|
||||
dirty: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> Option<Vec<u8>> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.untrackedCache=false",
|
||||
])
|
||||
.args(args)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.output()
|
||||
.ok()?;
|
||||
output.status.success().then_some(output.stdout)
|
||||
}
|
||||
|
||||
fn status_paths(root: &Path) -> Option<BTreeSet<String>> {
|
||||
let output = git(
|
||||
root,
|
||||
&["status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
||||
)?;
|
||||
let mut entries = output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|entry| !entry.is_empty());
|
||||
let mut paths = BTreeSet::new();
|
||||
while let Some(entry) = entries.next() {
|
||||
let path = std::str::from_utf8(entry.get(3..)?).ok()?;
|
||||
paths.insert(path.to_string());
|
||||
if entry[..2].iter().any(|byte| matches!(byte, b'R' | b'C')) {
|
||||
// Porcelain -z emits the destination first, then the source.
|
||||
if let Some(source) = entries.next() {
|
||||
paths.insert(std::str::from_utf8(source).ok()?.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
(paths.len() <= MAX_BASELINE_PATHS).then_some(paths)
|
||||
}
|
||||
|
||||
fn fingerprint(root: &Path, relative: &str) -> Option<String> {
|
||||
let mut parent = root.to_path_buf();
|
||||
let components = Path::new(relative).components().collect::<Vec<_>>();
|
||||
for component in components.iter().take(components.len().saturating_sub(1)) {
|
||||
parent.push(component);
|
||||
if fs::symlink_metadata(&parent).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
|
||||
return Some("symlink_ancestor".into());
|
||||
}
|
||||
}
|
||||
let path = root.join(relative);
|
||||
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Some("missing".into());
|
||||
}
|
||||
Err(_) => return None,
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Some(format!("symlink:{}", fs::read_link(&path).ok()?.display()));
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Some("non_file".into());
|
||||
}
|
||||
let mut file = fs::File::open(&path).ok()?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0_u8; 65536];
|
||||
loop {
|
||||
let count = file.read(&mut buffer).ok()?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
Some(format!("sha256:{:x}", hash.finalize()))
|
||||
}
|
||||
|
||||
impl DeliveryEvidence {
|
||||
pub(super) fn capture(spec: &AgentWorkerSpec) -> Self {
|
||||
let baseline = spec
|
||||
.runtime_profile
|
||||
.permissions
|
||||
.write
|
||||
.then(|| {
|
||||
let root =
|
||||
String::from_utf8(git(&spec.workspace, &["rev-parse", "--show-toplevel"])?)
|
||||
.ok()?;
|
||||
let root = PathBuf::from(root.trim());
|
||||
let head = git(&root, &["rev-parse", "--verify", "HEAD"])
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.map(|head| head.trim().to_string());
|
||||
let dirty = status_paths(&root)?
|
||||
.into_iter()
|
||||
.map(|path| fingerprint(&root, &path).map(|hash| (path, hash)))
|
||||
.collect::<Option<BTreeMap<_, _>>>()?;
|
||||
Some(GitDeliveryBaseline { root, head, dirty })
|
||||
})
|
||||
.flatten();
|
||||
Self {
|
||||
baseline,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn changed_paths(&self, workspace: &Path) -> Option<BTreeSet<String>> {
|
||||
let baseline = self.baseline.as_ref()?;
|
||||
// Persisted evidence is data, never authority to inspect another tree
|
||||
// or pass caller-controlled options to git after a restart.
|
||||
let current_root =
|
||||
String::from_utf8(git(workspace, &["rev-parse", "--show-toplevel"])?).ok()?;
|
||||
if baseline.root != Path::new(current_root.trim())
|
||||
|| baseline.dirty.len() > MAX_BASELINE_PATHS
|
||||
|| baseline
|
||||
.dirty
|
||||
.keys()
|
||||
.any(|path| normalize_claim_path(path).as_ref() != Ok(path))
|
||||
|| baseline.head.as_ref().is_some_and(|head| {
|
||||
!matches!(head.len(), 40 | 64) || !head.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut candidates = status_paths(&baseline.root)?;
|
||||
candidates.extend(baseline.dirty.keys().cloned());
|
||||
if let Some(head) = baseline.head.as_deref() {
|
||||
let output = git(
|
||||
&baseline.root,
|
||||
&[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--name-only",
|
||||
"-z",
|
||||
head,
|
||||
"HEAD",
|
||||
"--",
|
||||
],
|
||||
)?;
|
||||
for path in output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
candidates.insert(std::str::from_utf8(path).ok()?.to_string());
|
||||
}
|
||||
}
|
||||
let workspace = workspace.canonicalize().ok()?;
|
||||
let mut changed = BTreeSet::new();
|
||||
for path in candidates {
|
||||
let absolute = baseline.root.join(&path);
|
||||
if let Some(before) = baseline.dirty.get(&path)
|
||||
&& fingerprint(&baseline.root, &path).as_ref() == Some(before)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(relative) = absolute.strip_prefix(&workspace) else {
|
||||
continue;
|
||||
};
|
||||
changed.insert(relative.to_string_lossy().to_string());
|
||||
}
|
||||
Some(changed)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn declared_paths(
|
||||
paths: &[String],
|
||||
legacy: Option<&str>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if paths.len() > MAX_DELIVERABLES {
|
||||
return Err(format!(
|
||||
"deliverables accepts at most {MAX_DELIVERABLES} paths"
|
||||
));
|
||||
}
|
||||
let mut paths = paths.to_vec();
|
||||
if paths.is_empty()
|
||||
&& let Some(legacy) = legacy
|
||||
&& !legacy.chars().any(char::is_whitespace)
|
||||
&& (legacy.contains('/') || legacy.contains('.'))
|
||||
{
|
||||
paths.push(legacy.to_string());
|
||||
}
|
||||
let mut normalized = Vec::new();
|
||||
for path in paths {
|
||||
let path = normalize_claim_path(&path)?;
|
||||
if path == "."
|
||||
|| path
|
||||
.split('/')
|
||||
.any(|part| part.eq_ignore_ascii_case(".git"))
|
||||
{
|
||||
return Err("deliverables must name files outside git metadata".into());
|
||||
}
|
||||
if !normalized.contains(&path) {
|
||||
normalized.push(path);
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(super) fn safe_deliverable_path(workspace: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let path = declared_paths(&[path.to_string()], None)?
|
||||
.pop()
|
||||
.ok_or_else(|| "deliverable must name a file".to_string())?;
|
||||
let root = workspace
|
||||
.canonicalize()
|
||||
.map_err(|_| "deliverable workspace is unavailable".to_string())?;
|
||||
let mut resolved = root.clone();
|
||||
for component in Path::new(&path).components() {
|
||||
resolved.push(component);
|
||||
match fs::symlink_metadata(&resolved) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err("deliverable path traverses a symlink".into());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => return Err("deliverable path cannot be inspected".into()),
|
||||
}
|
||||
}
|
||||
if resolved == root || !resolved.starts_with(root) {
|
||||
return Err("deliverable must be a file inside the worker workspace".into());
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub(super) fn check_deliverable(workspace: &Path, path: &str, allowed: bool) -> DeliverableVerdict {
|
||||
let mut verdict = DeliverableVerdict {
|
||||
path: path.into(),
|
||||
status: "out_of_scope".into(),
|
||||
bytes: None,
|
||||
};
|
||||
if !allowed {
|
||||
return verdict;
|
||||
}
|
||||
let resolved = match safe_deliverable_path(workspace, path) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
verdict.status = "invalid_path".into();
|
||||
return verdict;
|
||||
}
|
||||
};
|
||||
match fs::symlink_metadata(resolved) {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
verdict.bytes = Some(metadata.len());
|
||||
verdict.status = if metadata.len() == 0 {
|
||||
"empty"
|
||||
} else {
|
||||
"present"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Ok(_) => verdict.status = "not_file".into(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
verdict.status = "missing".into()
|
||||
}
|
||||
Err(_) => verdict.status = "unreadable".into(),
|
||||
}
|
||||
verdict
|
||||
}
|
||||
|
||||
fn citation(token: &str) -> bool {
|
||||
let Some((_, line)) = token.rsplit_once(':') else {
|
||||
return false;
|
||||
};
|
||||
let mut numbers = line.split('-');
|
||||
let numeric = |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit());
|
||||
numbers.next().is_some_and(numeric)
|
||||
&& numbers.next().is_none_or(numeric)
|
||||
&& numbers.next().is_none()
|
||||
}
|
||||
|
||||
pub(super) fn explicit_change_paths(summary: &str) -> BTreeSet<String> {
|
||||
let mut paths = BTreeSet::new();
|
||||
let mut in_changes = false;
|
||||
for line in summary.lines() {
|
||||
let line = line.trim().trim_start_matches('#').trim();
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let declaration = ["changes:", "changed files:", "files changed:"]
|
||||
.into_iter()
|
||||
.find(|prefix| lower.starts_with(prefix));
|
||||
let content = if let Some(prefix) = declaration {
|
||||
in_changes = true;
|
||||
&line[prefix.len()..]
|
||||
} else if in_changes && (line.starts_with('-') || line.starts_with('*')) {
|
||||
line
|
||||
} else {
|
||||
in_changes = false;
|
||||
continue;
|
||||
};
|
||||
if matches!(
|
||||
content
|
||||
.trim()
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"none" | "no files changed" | "no changes"
|
||||
) {
|
||||
in_changes = false;
|
||||
continue;
|
||||
}
|
||||
for token in content.split_whitespace() {
|
||||
let token = token.trim_matches(|ch: char| {
|
||||
matches!(
|
||||
ch,
|
||||
'`' | '"' | '\'' | ',' | ';' | '(' | ')' | '[' | ']' | '*' | '-'
|
||||
)
|
||||
});
|
||||
if citation(token)
|
||||
|| token.contains("://")
|
||||
|| (!token.contains('/') && !token.contains('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(path) = normalize_claim_path(token)
|
||||
&& path != "."
|
||||
{
|
||||
paths.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub(super) fn verify_changes(
|
||||
summary: &str,
|
||||
write_capable: bool,
|
||||
evidence: &DeliveryEvidence,
|
||||
changed: Option<&BTreeSet<String>>,
|
||||
owned_changes: &BTreeSet<String>,
|
||||
declared_outputs: &BTreeSet<String>,
|
||||
) -> Option<AgentRunVerificationSummary> {
|
||||
if !write_capable {
|
||||
return None;
|
||||
}
|
||||
let claimed = explicit_change_paths(summary);
|
||||
let missing = changed
|
||||
.map(|changed| claimed.difference(changed).cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let observed = evidence
|
||||
.observed_writes
|
||||
.union(owned_changes)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let undeclared = observed
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
changed.is_none_or(|changed| changed.contains(*path))
|
||||
&& !claimed.contains(*path)
|
||||
&& !declared_outputs.contains(*path)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if missing.is_empty() && undeclared.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(AgentRunVerificationSummary {
|
||||
status: "claim_mismatch".into(),
|
||||
summary: format!(
|
||||
"Compared with the workspace at spawn: declared but unchanged: {missing:?}; observed changes without a change declaration: {undeclared:?}. Inspect the worker receipt."
|
||||
),
|
||||
deliverables: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn repository(root: &Path) {
|
||||
git(root, &["init", "--quiet"]);
|
||||
git(root, &["config", "user.name", "Delivery test"]);
|
||||
git(root, &["config", "user.email", "[email protected]"]);
|
||||
fs::create_dir_all(root.join("src")).unwrap();
|
||||
fs::write(root.join("src/lib.rs"), "baseline\n").unwrap();
|
||||
git(root, &["add", "--", "src/lib.rs"]);
|
||||
git(root, &["commit", "--quiet", "-m", "baseline"]);
|
||||
}
|
||||
|
||||
fn worker(root: &Path, write: bool, paths: &[&str], scope: &[&str]) -> (SubAgentManager, String) {
|
||||
let mut manager = SubAgentManager::new(root.to_path_buf(), 2);
|
||||
let id = manager.insert_test_running_agent("delivery", root);
|
||||
let record = manager.worker_records.get_mut(&id).unwrap();
|
||||
record.spec.runtime_profile.permissions.write = write;
|
||||
record.spec.launch_manifest = Some(ChildLaunchManifest {
|
||||
owner_session: "workspace".into(),
|
||||
child_id: id.clone(),
|
||||
profile: record.spec.runtime_profile.clone(),
|
||||
prompt: "produce report".into(),
|
||||
cwd: Some(root.display().to_string()),
|
||||
worktree: false,
|
||||
writable_roots: scope.iter().map(|path| (*path).into()).collect(),
|
||||
writable_files: Vec::new(),
|
||||
coordination_contracts: Vec::new(),
|
||||
expected_artifact: None,
|
||||
deliverables: paths.iter().map(|path| (*path).into()).collect(),
|
||||
token_budget: None,
|
||||
resume_identity: None,
|
||||
generation: 1,
|
||||
resume_from_agent_id: None,
|
||||
});
|
||||
record.delivery_evidence = DeliveryEvidence::capture(&record.spec);
|
||||
if write && !scope.is_empty() {
|
||||
manager
|
||||
.coordination
|
||||
.register_claim(
|
||||
WriteScopeClaim {
|
||||
owner: id.clone(),
|
||||
roots: scope.iter().map(|path| (*path).into()).collect(),
|
||||
exact_files: Vec::new(),
|
||||
contracts: Vec::new(),
|
||||
},
|
||||
false,
|
||||
|_| false,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
(manager, id)
|
||||
}
|
||||
|
||||
fn complete(manager: &mut SubAgentManager, id: &str, report: &str) -> AgentRunVerificationSummary {
|
||||
let mut result = manager.get_result(id).unwrap();
|
||||
result.status = SubAgentStatus::Completed;
|
||||
result.result = Some(report.into());
|
||||
manager.complete_worker_from_result(id, &result);
|
||||
manager.worker_records[id].verification.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_deliverables_narrow_default_scope_and_reject_invalid_input() {
|
||||
let request = parse_spawn_request(&json!({
|
||||
"type": "implement", "prompt": "write outputs", "deliverables": ["tmp/a/report.md", "tmp/b/report.md"]
|
||||
})).unwrap();
|
||||
assert!(request.write_roots.is_empty());
|
||||
assert_eq!(request.exact_files, ["tmp/a/report.md", "tmp/b/report.md"]);
|
||||
for paths in [
|
||||
json!([""]),
|
||||
json!(["../report.md"]),
|
||||
json!(["/tmp/report.md"]),
|
||||
json!([".git/config"]),
|
||||
json!(["."]),
|
||||
json!([1]),
|
||||
json!("report.md"),
|
||||
] {
|
||||
assert!(
|
||||
parse_spawn_request(
|
||||
&json!({"type":"implement", "prompt":"report", "deliverables": paths})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
parse_spawn_request(
|
||||
&json!({"type":"implement", "prompt":"report", "deliverables": vec!["x.md"; 17]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
delivery::declared_paths(&[], Some("review findings"))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
delivery::declared_paths(&[], Some("report.md")).unwrap(),
|
||||
["report.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliverable_verdict_distinguishes_present_missing_empty_directory_and_scope() {
|
||||
let tmp = tempdir().unwrap();
|
||||
fs::write(tmp.path().join("present.md"), "report").unwrap();
|
||||
fs::write(tmp.path().join("empty.md"), "").unwrap();
|
||||
fs::create_dir(tmp.path().join("directory")).unwrap();
|
||||
for (path, status) in [
|
||||
("present.md", "present"),
|
||||
("missing.md", "missing"),
|
||||
("empty.md", "empty"),
|
||||
("directory", "not_file"),
|
||||
] {
|
||||
assert_eq!(
|
||||
delivery::check_deliverable(tmp.path(), path, true).status,
|
||||
status
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
delivery::check_deliverable(tmp.path(), "present.md", false).status,
|
||||
"out_of_scope"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn deliverables_refuse_leaf_and_parent_symlink_escape() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let outside = tempdir().unwrap();
|
||||
fs::write(outside.path().join("secret.md"), "outside").unwrap();
|
||||
std::os::unix::fs::symlink(outside.path().join("secret.md"), tmp.path().join("leaf.md"))
|
||||
.unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), tmp.path().join("linked")).unwrap();
|
||||
for path in ["leaf.md", "linked/secret.md"] {
|
||||
assert_eq!(
|
||||
delivery::check_deliverable(tmp.path(), path, true).status,
|
||||
"invalid_path"
|
||||
);
|
||||
assert!(delivery::safe_deliverable_path(tmp.path(), path).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_declared_deliverable_is_visible_in_terminal_sentinel() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (mut manager, id) = worker(tmp.path(), true, &["report.md"], &["."]);
|
||||
let verification = complete(&mut manager, &id, "Finished the research.");
|
||||
assert_eq!(verification.status, "deliverable_missing");
|
||||
assert_eq!(verification.deliverables[0].status, "missing");
|
||||
let mut result = manager.get_result(&id).unwrap();
|
||||
result.status = SubAgentStatus::Completed;
|
||||
let completion =
|
||||
subagent_completion_with_verification("workspace", &result, None, Some(&verification));
|
||||
assert!(completion.payload.contains("deliverable_missing"));
|
||||
assert!(completion.payload.contains("report.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_output_is_not_an_undeclared_edit_and_undeclared_worker_stays_self_reported() {
|
||||
let tmp = tempdir().unwrap();
|
||||
repository(tmp.path());
|
||||
let (mut manager, id) = worker(tmp.path(), true, &["report.md"], &["."]);
|
||||
fs::write(tmp.path().join("report.md"), "findings").unwrap();
|
||||
let verification = complete(&mut manager, &id, "Report ready.");
|
||||
assert_eq!(verification.status, "deliverables_present");
|
||||
assert_eq!(verification.deliverables[0].bytes, Some(8));
|
||||
let (mut manager, id) = worker(tmp.path(), false, &[], &[]);
|
||||
assert_eq!(
|
||||
complete(&mut manager, &id, "No changes.").status,
|
||||
"self_report_only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_like_prose_and_line_citations_are_never_edit_claims() {
|
||||
for report in [
|
||||
"Fixed behavior is documented in src/lib.rs:12-19.",
|
||||
"CHANGES: None\nThe added guard is at src/lib.rs:12-19",
|
||||
"CHANGES: src/lib.rs:12-19",
|
||||
] {
|
||||
assert!(
|
||||
delivery::explicit_change_paths(report).is_empty(),
|
||||
"{report}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
delivery::explicit_change_paths("CHANGES:\n- src/lib.rs\n- report.md"),
|
||||
BTreeSet::from(["src/lib.rs".into(), "report.md".into()])
|
||||
);
|
||||
let tmp = tempdir().unwrap();
|
||||
repository(tmp.path());
|
||||
let (mut manager, id) = worker(tmp.path(), false, &[], &[]);
|
||||
assert_eq!(
|
||||
complete(&mut manager, &id, "CHANGES: src/lib.rs").status,
|
||||
"self_report_only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_dirty_file_does_not_satisfy_a_new_edit_claim() {
|
||||
let tmp = tempdir().unwrap();
|
||||
repository(tmp.path());
|
||||
fs::write(tmp.path().join("src/lib.rs"), "existing dirty work\n").unwrap();
|
||||
let (mut manager, id) = worker(tmp.path(), true, &[], &["src"]);
|
||||
let verification = complete(&mut manager, &id, "CHANGES: src/lib.rs");
|
||||
assert_eq!(verification.status, "claim_mismatch");
|
||||
assert!(verification.summary.contains("declared but unchanged"));
|
||||
assert!(verification.summary.contains("src/lib.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modification_of_already_dirty_file_is_measured_against_spawn_content() {
|
||||
let tmp = tempdir().unwrap();
|
||||
repository(tmp.path());
|
||||
fs::write(tmp.path().join("src/lib.rs"), "existing dirty work\n").unwrap();
|
||||
let (mut manager, id) = worker(tmp.path(), true, &[], &["src"]);
|
||||
fs::write(
|
||||
tmp.path().join("src/lib.rs"),
|
||||
"worker changed this further\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
complete(&mut manager, &id, "CHANGES: src/lib.rs").status,
|
||||
"self_report_only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_change_is_compared_with_exact_spawn_head_without_timestamp_guessing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
repository(tmp.path());
|
||||
let (mut manager, id) = worker(tmp.path(), true, &[], &["src"]);
|
||||
fs::write(tmp.path().join("src/lib.rs"), "worker commit\n").unwrap();
|
||||
git(tmp.path(), &["add", "--", "src/lib.rs"]);
|
||||
git(tmp.path(), &["commit", "--quiet", "-m", "worker"]);
|
||||
assert_eq!(
|
||||
complete(&mut manager, &id, "CHANGES: src/lib.rs").status,
|
||||
"self_report_only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owned_write_without_a_declaration_is_flagged_but_peer_changes_are_not() {
|
||||
let tmp = tempdir().unwrap();
|
||||
repository(tmp.path());
|
||||
let (mut manager, id) = worker(tmp.path(), true, &[], &["src"]);
|
||||
fs::write(tmp.path().join("src/lib.rs"), "new change\n").unwrap();
|
||||
assert_eq!(
|
||||
complete(&mut manager, &id, "CHANGES: None").status,
|
||||
"claim_mismatch"
|
||||
);
|
||||
let (mut manager, id) = worker(tmp.path(), true, &[], &["reports"]);
|
||||
fs::write(tmp.path().join("src/lib.rs"), "peer change\n").unwrap();
|
||||
assert_eq!(
|
||||
complete(&mut manager, &id, "CHANGES: None").status,
|
||||
"self_report_only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_sibling_write_paths_admit_and_ancestor_overlap_names_actual_remedy() {
|
||||
let mut ledger = CoordinationLedger::default();
|
||||
let claim = |owner: &str, path: &str| WriteScopeClaim {
|
||||
owner: owner.into(),
|
||||
roots: vec![path.into()],
|
||||
exact_files: Vec::new(),
|
||||
contracts: Vec::new(),
|
||||
};
|
||||
ledger
|
||||
.register_claim(claim("a", "tmp/scan/a"), false, |_| true)
|
||||
.unwrap();
|
||||
ledger
|
||||
.register_claim(claim("b", "tmp/scan/b"), false, |_| true)
|
||||
.unwrap();
|
||||
let error = ledger
|
||||
.register_claim(claim("broad", "tmp/scan"), false, |_| true)
|
||||
.unwrap_err();
|
||||
for text in [
|
||||
"tmp/scan/a",
|
||||
"tmp/scan",
|
||||
"disjoint sibling",
|
||||
"exact_files",
|
||||
"write_authority=read_only",
|
||||
] {
|
||||
assert!(error.contains(text), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -87,10 +87,13 @@ use coord::{
|
||||
|
||||
pub mod advisor;
|
||||
pub mod coord;
|
||||
mod delivery;
|
||||
pub mod mailbox;
|
||||
mod naming;
|
||||
mod worktree;
|
||||
|
||||
pub use delivery::{DeliverableVerdict, DeliveryEvidence};
|
||||
|
||||
use worktree::{SubAgentWorktreeRequest, prepare_child_workspace};
|
||||
#[cfg(test)]
|
||||
use worktree::{create_isolated_worktree, git_repo_root};
|
||||
@@ -762,6 +765,8 @@ pub struct AgentRunUsage {
|
||||
pub struct AgentRunVerificationSummary {
|
||||
pub status: String,
|
||||
pub summary: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deliverables: Vec<DeliverableVerdict>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -813,6 +818,8 @@ pub struct AgentWorkerRecord {
|
||||
/// retried delivery idempotent in both projections after reload.
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub usage_source_fingerprints: BTreeSet<String>,
|
||||
#[serde(default)]
|
||||
pub delivery_evidence: DeliveryEvidence,
|
||||
#[serde(default = "default_agent_run_verification")]
|
||||
pub verification: AgentRunVerificationSummary,
|
||||
#[serde(default = "default_agent_run_recommended_action")]
|
||||
@@ -862,6 +869,19 @@ impl AgentWorkerRecord {
|
||||
let artifacts = default_subagent_artifacts(&run_id);
|
||||
let follow_up = follow_up_target_for_spec(&spec);
|
||||
let takeover = takeover_target_for_spec(&spec);
|
||||
let delivery_evidence = DeliveryEvidence::capture(&spec);
|
||||
let mut verification = default_agent_run_verification();
|
||||
if let Some(manifest) = spec.launch_manifest.as_ref() {
|
||||
verification.deliverables = manifest
|
||||
.deliverables
|
||||
.iter()
|
||||
.map(|path| DeliverableVerdict {
|
||||
path: path.clone(),
|
||||
status: "pending".to_string(),
|
||||
bytes: None,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let recommended_action =
|
||||
recommended_action_for_worker_status(AgentWorkerStatus::Starting, &spec);
|
||||
Self {
|
||||
@@ -874,7 +894,8 @@ impl AgentWorkerRecord {
|
||||
artifacts,
|
||||
usage: default_agent_run_usage(),
|
||||
usage_source_fingerprints: BTreeSet::new(),
|
||||
verification: default_agent_run_verification(),
|
||||
delivery_evidence,
|
||||
verification,
|
||||
recommended_action,
|
||||
status: AgentWorkerStatus::Starting,
|
||||
created_at_ms: now_ms,
|
||||
@@ -1171,125 +1192,10 @@ fn default_agent_run_verification() -> AgentRunVerificationSummary {
|
||||
summary:
|
||||
"No verified command or test receipt is attached; treat the result summary as a child self-report."
|
||||
.to_string(),
|
||||
deliverables: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare a completed child's claimed changed-files against `git status`
|
||||
/// in its workspace (R7, finish-operator 2026-08-02). The morning report
|
||||
/// caught a child claiming edits git had never seen — by hand. Extraction
|
||||
/// is deliberately conservative to keep taint high-signal: only path-like
|
||||
/// tokens on a line that also carries a change verb count as claims, and a
|
||||
/// claim is a mismatch only when git shows the path untouched. Returns
|
||||
/// `None` when there is nothing to dispute (no git, no claims, all claims
|
||||
/// visible in the status).
|
||||
fn claimed_diff_taint(
|
||||
summary: &str,
|
||||
workspace: &Path,
|
||||
worker_started_at_ms: Option<u64>,
|
||||
) -> Option<AgentRunVerificationSummary> {
|
||||
const CHANGE_VERBS: [&str; 14] = [
|
||||
"changed",
|
||||
"modified",
|
||||
"updated",
|
||||
"edited",
|
||||
"wrote",
|
||||
"rewrote",
|
||||
"created",
|
||||
"added",
|
||||
"deleted",
|
||||
"removed",
|
||||
"renamed",
|
||||
"fixed",
|
||||
"patched",
|
||||
"implemented",
|
||||
];
|
||||
|
||||
let status_output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args(["status", "--porcelain"])
|
||||
// Read-only probe: never take the index lock in the user's repo
|
||||
// (#5617).
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !status_output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut dirty: std::collections::HashSet<String> =
|
||||
String::from_utf8_lossy(&status_output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let entry = line.get(3..)?.trim();
|
||||
// Renames report "old -> new"; the new path is the claimable one.
|
||||
let path = entry.rsplit(" -> ").next().unwrap_or(entry);
|
||||
Some(path.trim_matches('"').to_string())
|
||||
})
|
||||
.collect();
|
||||
// A child that committed its work leaves git status clean; files changed
|
||||
// by commits made after the worker started are visible claims too.
|
||||
if let Some(started_ms) = worker_started_at_ms
|
||||
&& let Some(since) =
|
||||
chrono::DateTime::from_timestamp_millis(i64::try_from(started_ms).unwrap_or(i64::MAX))
|
||||
&& let Ok(log_output) = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(workspace)
|
||||
.args([
|
||||
"log",
|
||||
"--name-only",
|
||||
"--pretty=format:",
|
||||
&format!("--since={}", since.to_rfc3339()),
|
||||
])
|
||||
.output()
|
||||
&& log_output.status.success()
|
||||
{
|
||||
dirty.extend(
|
||||
String::from_utf8_lossy(&log_output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(str::to_string),
|
||||
);
|
||||
}
|
||||
|
||||
let mut mismatched: Vec<String> = Vec::new();
|
||||
for line in summary.lines() {
|
||||
let words: std::collections::HashSet<String> = line
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect();
|
||||
if !CHANGE_VERBS.iter().any(|verb| words.contains(*verb)) {
|
||||
continue;
|
||||
}
|
||||
for token in line.split_whitespace() {
|
||||
let token = token.trim_matches(|c: char| !(c.is_ascii_alphanumeric() || c == '/'));
|
||||
// Path-shaped: has a directory separator and an extension-ish dot.
|
||||
if !token.contains('/') || !token.contains('.') || token.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let claimed = token.trim_start_matches("./").to_string();
|
||||
if dirty.contains(&claimed) {
|
||||
continue;
|
||||
}
|
||||
// A tracked-and-clean or nonexistent path claimed as changed is
|
||||
// the mismatch; a dirty or renamed path is a visible claim.
|
||||
if !mismatched.contains(&claimed) {
|
||||
mismatched.push(claimed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if mismatched.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(AgentRunVerificationSummary {
|
||||
status: "claim_mismatch".to_string(),
|
||||
summary: format!(
|
||||
"Result claims changed file(s) that git status does not show at delivery: {}. Treat the self-report as unverified and inspect the transcript.",
|
||||
mismatched.join(", ")
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn default_agent_run_recommended_action() -> AgentRunRecommendedAction {
|
||||
AgentRunRecommendedAction {
|
||||
action: "inspect_transcript".to_string(),
|
||||
@@ -1653,6 +1559,7 @@ pub(crate) struct SubAgentSpawnOptions {
|
||||
pub write_claim: Option<WriteScopeClaim>,
|
||||
pub isolated_worktree: bool,
|
||||
pub expected_artifact: Option<String>,
|
||||
pub deliverables: Vec<String>,
|
||||
/// Source agent id this child continues, stamped into the ChildLaunchManifest
|
||||
/// for receipt traceability.
|
||||
pub resume_from_agent_id: Option<String>,
|
||||
@@ -1897,6 +1804,7 @@ struct SpawnRequest {
|
||||
/// Declared expected artifact. Surfaced to the child in its prompt so the
|
||||
/// contract the spawner declared is visible to the agent doing the work.
|
||||
expected_artifact: Option<String>,
|
||||
deliverables: Vec<String>,
|
||||
/// Expected mutation boundary. Write-capable launches without an explicit
|
||||
/// root, exact file, or named contract default to the parent workspace
|
||||
/// root (`"."`). Escalation outside the parent workspace is refused.
|
||||
@@ -2171,12 +2079,13 @@ impl SubAgentTerminalDeliveryContext {
|
||||
/// Publish to every live sink without blocking or awaiting while the
|
||||
/// manager owns the terminal claim. The public agent/worker states remain
|
||||
/// Running until all three sends have been attempted.
|
||||
fn deliver(&self, result: &SubAgentResult) {
|
||||
fn deliver(&self, result: &SubAgentResult, verification: Option<&AgentRunVerificationSummary>) {
|
||||
let report_ref = spill_subagent_final_report(&self.session_id, result);
|
||||
let completion = subagent_completion_from_result_with_ref_for_session(
|
||||
let completion = subagent_completion_with_verification(
|
||||
&self.session_id,
|
||||
result,
|
||||
report_ref.as_deref(),
|
||||
verification,
|
||||
);
|
||||
|
||||
if self.spawn_depth > 0
|
||||
@@ -5525,14 +5434,100 @@ impl SubAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_worker_delivery(&mut self, worker_id: &str, result: &SubAgentResult) {
|
||||
let Some(record) = self.worker_records.get(worker_id) else {
|
||||
return;
|
||||
};
|
||||
if record.delivery_evidence.checked || result.status == SubAgentStatus::Running {
|
||||
return;
|
||||
}
|
||||
let workspace = &record.spec.workspace;
|
||||
let changed = record.delivery_evidence.changed_paths(workspace);
|
||||
let owned_changes = changed
|
||||
.as_ref()
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
self.validate_write_scope(worker_id, std::slice::from_ref(path))
|
||||
.is_ok()
|
||||
})
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut verification = delivery::verify_changes(
|
||||
result.result.as_deref().unwrap_or_default(),
|
||||
record.spec.runtime_profile.permissions.write,
|
||||
&record.delivery_evidence,
|
||||
changed.as_ref(),
|
||||
&owned_changes,
|
||||
&record
|
||||
.spec
|
||||
.launch_manifest
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.deliverables.iter().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_else(default_agent_run_verification);
|
||||
let paths = record
|
||||
.spec
|
||||
.launch_manifest
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.deliverables.as_slice())
|
||||
.unwrap_or_default();
|
||||
verification.deliverables = paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let allowed = record.spec.runtime_profile.permissions.write
|
||||
&& self
|
||||
.validate_write_scope(worker_id, std::slice::from_ref(path))
|
||||
.is_ok();
|
||||
delivery::check_deliverable(workspace, path, allowed)
|
||||
})
|
||||
.collect();
|
||||
let missing = verification
|
||||
.deliverables
|
||||
.iter()
|
||||
.filter(|verdict| verdict.status != "present")
|
||||
.map(|verdict| format!("{} ({})", verdict.path, verdict.status))
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
let prior = if verification.status == "claim_mismatch" {
|
||||
format!(" {}", verification.summary)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
verification.status = "deliverable_missing".to_string();
|
||||
verification.summary = format!(
|
||||
"Declared deliverables not produced as non-empty files in the worker write scope: {}.{prior}",
|
||||
missing.join(", ")
|
||||
);
|
||||
} else if !paths.is_empty() && verification.status == "self_report_only" {
|
||||
verification.status = "deliverables_present".to_string();
|
||||
verification.summary = "Declared files exist and are non-empty inside the worker write scope; their contents remain a worker self-report.".to_string();
|
||||
}
|
||||
if let Some(record) = self.worker_records.get_mut(worker_id) {
|
||||
record.verification = verification;
|
||||
record.delivery_evidence.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_worker_from_result(&mut self, worker_id: &str, result: &SubAgentResult) {
|
||||
self.verify_worker_delivery(worker_id, result);
|
||||
let status = worker_status_from_subagent_result(result);
|
||||
let message = match &result.status {
|
||||
SubAgentStatus::Completed => Some("completed".to_string()),
|
||||
SubAgentStatus::Failed(err) => Some(err.clone()),
|
||||
SubAgentStatus::Interrupted(reason) => Some(reason.clone()),
|
||||
SubAgentStatus::Cancelled => Some("cancelled".to_string()),
|
||||
SubAgentStatus::BudgetExhausted => Some("token budget exhausted".to_string()),
|
||||
SubAgentStatus::BudgetExhausted => Some(
|
||||
result
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|checkpoint| checkpoint.reason.clone())
|
||||
.unwrap_or_else(|| "token budget exhausted".to_string()),
|
||||
),
|
||||
SubAgentStatus::Running => Some("running".to_string()),
|
||||
};
|
||||
if let Some(record) = self.worker_records.get_mut(worker_id) {
|
||||
@@ -5544,21 +5539,10 @@ impl SubAgentManager {
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.is_some_and(|checkpoint| checkpoint.parked_at_turn_end);
|
||||
if let SubAgentStatus::Failed(err) = &result.status {
|
||||
record.error = Some(err.clone());
|
||||
}
|
||||
// R7 (finish-operator 2026-08-02): a completed child's claimed
|
||||
// changed-files are checked against `git status` in its own
|
||||
// workspace at terminal delivery. A claim git cannot see taints
|
||||
// the verification summary the worker record already carries —
|
||||
// the parent keeps the result, but labeled, not trusted.
|
||||
if matches!(result.status, SubAgentStatus::Completed)
|
||||
&& let Some(summary_text) = result.result.as_deref()
|
||||
&& let Some(workspace) = result.workspace.as_deref()
|
||||
&& let Some(taint) =
|
||||
claimed_diff_taint(summary_text, workspace, Some(record.created_at_ms))
|
||||
{
|
||||
record.verification = taint;
|
||||
match &result.status {
|
||||
SubAgentStatus::Failed(err) => record.error = Some(err.clone()),
|
||||
SubAgentStatus::BudgetExhausted => record.error = message.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.record_worker_event(worker_id, status, message, Some(result.steps_taken), None);
|
||||
@@ -6725,6 +6709,29 @@ impl SubAgentManager {
|
||||
}
|
||||
};
|
||||
let write_capable = runtime_profile.permissions.write;
|
||||
let delivery_paths =
|
||||
delivery::declared_paths(&options.deliverables, options.expected_artifact.as_deref())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
for path in &delivery_paths {
|
||||
delivery::safe_deliverable_path(&agent.workspace, path).map_err(anyhow::Error::msg)?;
|
||||
let claimed_path = if options.claim_pre_namespaced && !options.isolated_worktree {
|
||||
let prefix = coordination_workspace_prefix(&self.workspace, &agent.workspace)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
namespace_coordination_path(&prefix, path).map_err(anyhow::Error::msg)?
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
if !write_capable
|
||||
|| !options
|
||||
.write_claim
|
||||
.as_ref()
|
||||
.is_some_and(|claim| claim.contains_path(&claimed_path))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"deliverable {path:?} is outside the worker write scope; declare an exact_files entry or a containing write_roots path"
|
||||
));
|
||||
}
|
||||
}
|
||||
if write_capable {
|
||||
// Isolated-worktree children mutate their own checkout, so they
|
||||
// do not contend for the shared-workspace process lock (#5036).
|
||||
@@ -6859,6 +6866,7 @@ impl SubAgentManager {
|
||||
.map(|record| record.claim.contracts.clone())
|
||||
.unwrap_or_default(),
|
||||
expected_artifact: options.expected_artifact.clone(),
|
||||
deliverables: delivery_paths.clone(),
|
||||
token_budget: options.token_budget,
|
||||
resume_identity: Some(agent.session_name.clone()),
|
||||
generation: 1,
|
||||
@@ -7567,12 +7575,17 @@ impl SubAgentManager {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
self.verify_worker_delivery(agent_id, &result);
|
||||
let delivery = self
|
||||
.agents
|
||||
.get(agent_id)
|
||||
.and_then(|agent| agent.terminal_delivery.clone());
|
||||
if let Some(delivery) = delivery {
|
||||
delivery.deliver(&result);
|
||||
let verification = self
|
||||
.worker_records
|
||||
.get(agent_id)
|
||||
.map(|record| &record.verification);
|
||||
delivery.deliver(&result, verification);
|
||||
}
|
||||
|
||||
self.update_from_result_with_persist(agent_id, result, persist_after_commit)
|
||||
@@ -9743,6 +9756,7 @@ async fn spawn_subagent_from_input(
|
||||
write_claim,
|
||||
isolated_worktree: spawn_request.worktree.is_some(),
|
||||
expected_artifact: spawn_request.expected_artifact.clone(),
|
||||
deliverables: spawn_request.deliverables.clone(),
|
||||
resume_from_agent_id: resume_from_agent_id.clone(),
|
||||
claim_pre_namespaced: false,
|
||||
preserve_runtime_profile: None,
|
||||
@@ -9782,6 +9796,14 @@ fn assemble_spawn_prompt(request: &SpawnRequest, resident: Option<&ResidentConte
|
||||
}
|
||||
None => prompt,
|
||||
};
|
||||
let prompt = if request.deliverables.is_empty() {
|
||||
prompt
|
||||
} else {
|
||||
format!(
|
||||
"{prompt}\n\nRequired file deliverables (checked at completion): {}",
|
||||
request.deliverables.join(", ")
|
||||
)
|
||||
};
|
||||
if request.dependencies.is_empty() && request.acceptance.is_empty() {
|
||||
return prompt;
|
||||
}
|
||||
@@ -10629,6 +10651,15 @@ pub(crate) fn subagent_completion_from_result_with_ref_for_session(
|
||||
owner_session_id: &str,
|
||||
result: &SubAgentResult,
|
||||
report_ref: Option<&str>,
|
||||
) -> SubAgentCompletion {
|
||||
subagent_completion_with_verification(owner_session_id, result, report_ref, None)
|
||||
}
|
||||
|
||||
fn subagent_completion_with_verification(
|
||||
owner_session_id: &str,
|
||||
result: &SubAgentResult,
|
||||
report_ref: Option<&str>,
|
||||
verification: Option<&AgentRunVerificationSummary>,
|
||||
) -> SubAgentCompletion {
|
||||
let raw = summarize_subagent_result(result);
|
||||
let mut evidence_truncated = false;
|
||||
@@ -10656,11 +10687,27 @@ pub(crate) fn subagent_completion_from_result_with_ref_for_session(
|
||||
let summary_truncated = truncated || evidence_truncated;
|
||||
let sentinel = match &result.status {
|
||||
SubAgentStatus::Failed(error) => subagent_failed_sentinel(result, error),
|
||||
SubAgentStatus::BudgetExhausted => {
|
||||
subagent_failed_sentinel(result, "child token budget exhausted")
|
||||
}
|
||||
SubAgentStatus::BudgetExhausted => subagent_failed_sentinel(
|
||||
result,
|
||||
result
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|checkpoint| checkpoint.reason.as_str())
|
||||
.unwrap_or("child token budget exhausted"),
|
||||
),
|
||||
_ => subagent_done_sentinel(&result.agent_id, result, summary_truncated),
|
||||
};
|
||||
let sentinel = if let Some(verification) = verification {
|
||||
// Both existing terminal sentinel forms carry the same delivery receipt.
|
||||
let opening = sentinel.find('>').expect("terminal sentinel opening");
|
||||
let closing = sentinel.rfind("</").expect("terminal sentinel closing");
|
||||
let mut payload: Value =
|
||||
serde_json::from_str(&sentinel[opening + 1..closing]).expect("terminal sentinel JSON");
|
||||
payload["verification"] = json!(verification);
|
||||
format!("{}{payload}{}", &sentinel[..=opening], &sentinel[closing..])
|
||||
} else {
|
||||
sentinel
|
||||
};
|
||||
let payload = match evidence_block {
|
||||
Some(evidence) => format!("{summary}\n{evidence}\n{sentinel}"),
|
||||
None => format!("{summary}\n{sentinel}"),
|
||||
@@ -12842,6 +12889,24 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
|
||||
.map(str::trim)
|
||||
.filter(|artifact| !artifact.is_empty())
|
||||
.map(str::to_string);
|
||||
if input
|
||||
.get("deliverables")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.as_str().is_some_and(|path| path.trim().is_empty()))
|
||||
})
|
||||
{
|
||||
return Err(ToolError::invalid_input(
|
||||
"deliverables paths must be non-empty",
|
||||
));
|
||||
}
|
||||
let deliverables = delivery::declared_paths(
|
||||
&parse_bounded_strings(input, "deliverables", delivery::MAX_DELIVERABLES)?,
|
||||
expected_artifact.as_deref(),
|
||||
)
|
||||
.map_err(ToolError::invalid_input)?;
|
||||
let write_authority_str = optional_input_str(input, &["write_authority", "writeAuthority"])?;
|
||||
if deliberate {
|
||||
let has_type = agent_type_explicit || profile.is_some();
|
||||
@@ -12852,7 +12917,7 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
|
||||
if workspace_policy_str.is_none() && worktree.is_none() {
|
||||
missing.push("workspace_policy (or worktree=true)");
|
||||
}
|
||||
if expected_artifact.is_none() {
|
||||
if expected_artifact.is_none() && deliverables.is_empty() {
|
||||
missing.push("expected_artifact");
|
||||
}
|
||||
if write_authority_str.is_none() {
|
||||
@@ -12956,6 +13021,7 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
|
||||
inherit_disallowed_tools,
|
||||
write_authority,
|
||||
expected_artifact,
|
||||
deliverables,
|
||||
write_roots,
|
||||
exact_files,
|
||||
coordination_contracts,
|
||||
@@ -13053,7 +13119,9 @@ fn validate_spawn_write_contract(
|
||||
&& request.exact_files.is_empty()
|
||||
&& request.coordination_contracts.is_empty()
|
||||
{
|
||||
if request.write_authority.is_some() || !allow_prompt_only_general {
|
||||
if !request.deliverables.is_empty() {
|
||||
request.exact_files = request.deliverables.clone();
|
||||
} else if request.write_authority.is_some() || !allow_prompt_only_general {
|
||||
// Default write scope to the parent workspace root. Escalation
|
||||
// outside the workspace is still refused by path normalization
|
||||
// when an explicit scope is declared.
|
||||
@@ -16304,6 +16372,8 @@ impl SubAgentToolRegistry {
|
||||
.validate_write_scope(&self.owner_agent_id, &paths)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
} else if self.enforce_write_claim
|
||||
// The typed read-only boundary above already rejected mutation.
|
||||
&& !self.write_is_denied()
|
||||
&& !is_internal_coordination_state_tool(name)
|
||||
// A shell run the read-only classifier proves mutation-free cannot
|
||||
// collide with the peer's writes no matter how contended the
|
||||
@@ -16342,7 +16412,7 @@ impl SubAgentToolRegistry {
|
||||
manager.live_peer_shared_write_claim_owners(&self.owner_agent_id);
|
||||
if !blocking_peers.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Tool {name} cannot prove a bounded file target, and another child is writing in this shared checkout (blocking peers: {}). Wait for the peer to finish, ask the parent to cancel it with agent(action=\"cancel\", agent_id=\"<peer>\"), run agent(action=\"release\") to clear a stale claim, or relaunch the children with worktree isolation.",
|
||||
"Tool {name} cannot prove a bounded file target or read-only execution while peers are writing in this shared checkout (blocking peers: {}). Use a bounded write tool or a proven read-only command, or run executable work in worktree isolation. Disjoint write_roots alone do not constrain arbitrary code.",
|
||||
blocking_peers.join(", ")
|
||||
));
|
||||
}
|
||||
@@ -16353,10 +16423,28 @@ impl SubAgentToolRegistry {
|
||||
.context()
|
||||
.clone()
|
||||
.with_owner_agent(self.owner_agent_id.clone(), self.owner_agent_name.clone());
|
||||
self.registry
|
||||
let observed_paths = if scope_aware_write {
|
||||
mutation_paths(name, &input)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let outcome = self
|
||||
.registry
|
||||
.execute_rich_full_with_context(name, input, Some(&context))
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map_err(|e| anyhow!(e));
|
||||
if outcome.as_ref().is_ok_and(|outcome| outcome.result.success)
|
||||
&& !observed_paths.is_empty()
|
||||
{
|
||||
let mut manager = self.coordination_manager.write().await;
|
||||
if let Some(record) = manager.worker_records.get_mut(&self.owner_agent_id) {
|
||||
record
|
||||
.delivery_evidence
|
||||
.observed_writes
|
||||
.extend(observed_paths);
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -235,6 +235,7 @@ fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> Ag
|
||||
writable_files: Vec::new(),
|
||||
coordination_contracts: Vec::new(),
|
||||
expected_artifact: Some("tested patch".to_string()),
|
||||
deliverables: Vec::new(),
|
||||
token_budget: None,
|
||||
resume_identity: Some(worker_id.to_string()),
|
||||
generation: 1,
|
||||
@@ -1142,6 +1143,7 @@ fn headless_worker_registration_enforces_live_claims_and_projects_context() {
|
||||
writable_files: Vec::new(),
|
||||
coordination_contracts: Vec::new(),
|
||||
expected_artifact: None,
|
||||
deliverables: Vec::new(),
|
||||
token_budget: None,
|
||||
resume_identity: Some(format!("fleet-{id}")),
|
||||
generation: 1,
|
||||
@@ -9345,6 +9347,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested(
|
||||
contracts: vec!["public-api".into()],
|
||||
}),
|
||||
expected_artifact: Some("tested patch".into()),
|
||||
deliverables: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let (first_id, contention) = {
|
||||
@@ -12175,24 +12178,9 @@ async fn contended_shared_writer_refusal_names_blocking_peer_and_remediation() {
|
||||
err.contains("agent_b"),
|
||||
"refusal must name the blocking peer: {err}"
|
||||
);
|
||||
// #5906: the remediation must name a surface that exists from here.
|
||||
// `agents/coordinate` left the catalog, so the refusal pointed at a tool
|
||||
// the refused child could not reach.
|
||||
assert!(
|
||||
err.contains(r#"agent(action="release")"#) && err.contains("worktree isolation"),
|
||||
"refusal must state concrete remediation: {err}"
|
||||
);
|
||||
assert!(
|
||||
AgentTool::new(
|
||||
new_shared_subagent_manager(tmp.path().to_path_buf(), 1),
|
||||
stub_runtime(),
|
||||
)
|
||||
.input_schema()["properties"]["action"]["enum"]
|
||||
.as_array()
|
||||
.expect("action enum")
|
||||
.iter()
|
||||
.any(|action| action == "release"),
|
||||
"the remediation the refusal names must be a real action on this tool"
|
||||
err.contains("bounded write tool") && err.contains("worktree isolation"),
|
||||
"refusal must state concrete remediation without telling a writer to remove a live peer: {err}"
|
||||
);
|
||||
assert!(
|
||||
!tmp.path().join("src/a2.txt").exists(),
|
||||
@@ -19970,13 +19958,15 @@ fn completed_claim_of_untouched_file_taints_verification() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_claim_repo(tmp.path());
|
||||
let mut manager = SubAgentManager::new(tmp.path().to_path_buf(), 2);
|
||||
manager.register_worker(make_worker_spec("agent_claims", tmp.path().to_path_buf()));
|
||||
let mut spec = make_worker_spec("agent_claims", tmp.path().to_path_buf());
|
||||
spec.runtime_profile.permissions.write = true;
|
||||
manager.register_worker(spec);
|
||||
|
||||
let mut snapshot = make_snapshot(SubAgentStatus::Completed);
|
||||
snapshot.agent_id = "agent_claims".to_string();
|
||||
snapshot.name = "agent_claims".to_string();
|
||||
snapshot.workspace = Some(tmp.path().to_path_buf());
|
||||
snapshot.result = Some("Fixed the bug: updated src/lib.rs and verified the fix.".to_string());
|
||||
snapshot.result = Some("CHANGES: src/lib.rs".to_string());
|
||||
manager.complete_worker_from_result("agent_claims", &snapshot);
|
||||
|
||||
let record = manager
|
||||
|
||||
@@ -356,6 +356,8 @@ pub struct ChildLaunchManifest {
|
||||
pub coordination_contracts: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_artifact: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub deliverables: Vec<String>,
|
||||
pub token_budget: Option<u64>,
|
||||
pub resume_identity: Option<String>,
|
||||
#[serde(default)]
|
||||
|
||||
Reference in new issue
Block a user