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:
Hunter BandClaude Fable 5.1 committed 2026-09-12 23:34:40 -07:00
1 parent b00afeb3af
commit 5a1cef906c
8 files changed
+953 -166

No files matched your search

+1
View File
@@ -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()
+2
View File
@@ -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
+400
View File
@@ -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}");
}
}
+232 -144
View File
@@ -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)]
+9 -19
View File
@@ -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
+2
View File
@@ -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)]