返回 CodeWhale
delivery.rs
根目录 / crates / tui / src / tools / subagent / delivery.rs
1 //! Delivery evidence replacing the old prose-verb/git-status heuristic.
2 //! The worker ledger retains the spawn baseline; this module only reads files.
3
4 use super::{
5 AgentRunVerificationSummary, AgentWorkerSpec, default_agent_run_verification,
6 normalize_claim_path,
7 };
8 use serde::{Deserialize, Serialize};
9 use sha2::{Digest as _, Sha256};
10 use std::collections::{BTreeMap, BTreeSet};
11 use std::fs;
12 use std::io::Read as _;
13 use std::path::{Path, PathBuf};
14 use std::process::Command;
15
16 pub(super) const MAX_DELIVERABLES: usize = 16;
17 const MAX_BASELINE_PATHS: usize = 4096;
18
19 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20 pub struct DeliverableVerdict {
21 pub path: String,
22 pub status: String,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub bytes: Option<u64>,
25 }
26
27 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
28 pub struct DeliveryEvidence {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 baseline: Option<GitDeliveryBaseline>,
31 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
32 pub(super) observed_writes: BTreeSet<String>,
33 #[serde(default)]
34 pub(super) checked: bool,
35 }
36
37 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38 struct GitDeliveryBaseline {
39 root: PathBuf,
40 head: Option<String>,
41 dirty: BTreeMap<String, String>,
42 }
43
44 fn git_command(root: &Path) -> Command {
45 let mut command = Command::new("git");
46 command
47 .arg("-C")
48 .arg(root)
49 .args([
50 "-c",
51 "core.fsmonitor=false",
52 "-c",
53 "core.untrackedCache=false",
54 ])
55 .env("GIT_OPTIONAL_LOCKS", "0")
56 .env("GIT_NO_LAZY_FETCH", "1");
57 command
58 }
59
60 fn git_output(root: &Path, args: &[&str]) -> Option<std::process::Output> {
61 git_command(root).args(args).output().ok()
62 }
63
64 fn git(root: &Path, args: &[&str]) -> Option<Vec<u8>> {
65 let output = git_output(root, args)?;
66 output.status.success().then_some(output.stdout)
67 }
68
69 /// `git` that reports stderr on failure, for checkpoint notes.
70 fn git_captured(root: &Path, args: &[&str]) -> Result<String, String> {
71 let output = git_output(root, args).ok_or_else(|| "git spawn failed".to_string())?;
72 if !output.status.success() {
73 return Err(first_line_lossy(&output.stderr, 200));
74 }
75 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
76 }
77
78 /// [`git_captured`] with `paths` fed to git's stdin NUL-terminated, for
79 /// commands reading `-z --stdin` or `--pathspec-from-file=- --pathspec-file-nul`.
80 /// No argv length limit applies, so no path count ever needs a wider
81 /// command instead of the exact list.
82 fn git_captured_paths(root: &Path, args: &[&str], paths: &[&str]) -> Result<String, String> {
83 use std::io::Write as _;
84 use std::process::Stdio;
85 let mut child = git_command(root)
86 .args(args)
87 .stdin(Stdio::piped())
88 .stdout(Stdio::piped())
89 .stderr(Stdio::piped())
90 .spawn()
91 .map_err(|_| "git spawn failed".to_string())?;
92 let mut input = Vec::new();
93 for path in paths {
94 input.extend_from_slice(path.as_bytes());
95 input.push(0);
96 }
97 if let Some(mut stdin) = child.stdin.take() {
98 // A write error means git exited early; its stderr says why.
99 let _ = stdin.write_all(&input);
100 }
101 let output = child
102 .wait_with_output()
103 .map_err(|_| "git wait failed".to_string())?;
104 if !output.status.success() {
105 return Err(first_line_lossy(&output.stderr, 200));
106 }
107 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
108 }
109
110 fn first_line_lossy(bytes: &[u8], max_chars: usize) -> String {
111 let line = String::from_utf8_lossy(bytes)
112 .lines()
113 .next()
114 .unwrap_or_default()
115 .trim()
116 .to_string();
117 if line.is_empty() {
118 return "git failed with no message".to_string();
119 }
120 line.chars().take(max_chars).collect()
121 }
122
123 fn status_paths(root: &Path) -> Option<BTreeSet<String>> {
124 let output = git(
125 root,
126 &["status", "--porcelain=v1", "-z", "--untracked-files=all"],
127 )?;
128 let mut entries = output
129 .split(|byte| *byte == 0)
130 .filter(|entry| !entry.is_empty());
131 let mut paths = BTreeSet::new();
132 while let Some(entry) = entries.next() {
133 let path = std::str::from_utf8(entry.get(3..)?).ok()?;
134 paths.insert(path.to_string());
135 if entry[..2].iter().any(|byte| matches!(byte, b'R' | b'C')) {
136 // Porcelain -z emits the destination first, then the source.
137 if let Some(source) = entries.next() {
138 paths.insert(std::str::from_utf8(source).ok()?.to_string());
139 }
140 }
141 }
142 (paths.len() <= MAX_BASELINE_PATHS).then_some(paths)
143 }
144
145 fn fingerprint(root: &Path, relative: &str) -> Option<String> {
146 let mut parent = root.to_path_buf();
147 let components = Path::new(relative).components().collect::<Vec<_>>();
148 for component in components.iter().take(components.len().saturating_sub(1)) {
149 parent.push(component);
150 if fs::symlink_metadata(&parent).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
151 return Some("symlink_ancestor".into());
152 }
153 }
154 let path = root.join(relative);
155
156 let metadata = match fs::symlink_metadata(&path) {
157 Ok(metadata) => metadata,
158 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
159 return Some("missing".into());
160 }
161 Err(_) => return None,
162 };
163 if metadata.file_type().is_symlink() {
164 return Some(format!("symlink:{}", fs::read_link(&path).ok()?.display()));
165 }
166 if !metadata.is_file() {
167 return Some("non_file".into());
168 }
169 let mut file = fs::File::open(&path).ok()?;
170 let mut hash = Sha256::new();
171 let mut buffer = [0_u8; 65536];
172 loop {
173 let count = file.read(&mut buffer).ok()?;
174 if count == 0 {
175 break;
176 }
177 hash.update(&buffer[..count]);
178 }
179 Some(format!(
180 "sha256:{}",
181 crate::hashing::hex_bytes(hash.finalize())
182 ))
183 }
184
185 impl DeliveryEvidence {
186 pub(super) fn capture(spec: &AgentWorkerSpec) -> Self {
187 Self::capture_for_handle(&spec.workspace, spec.runtime_profile.permissions.write)
188 }
189
190 /// Baseline capture that needs only the workspace and write permission —
191 /// the two spec fields the baseline actually reads. The async spawn path
192 /// calls this in `spawn_blocking` BEFORE the manager write lock (#6210)
193 /// and threads the evidence through registration, so git + file
194 /// fingerprints never run under the lock.
195 pub(super) fn capture_for_handle(workspace: &Path, write: bool) -> Self {
196 let baseline = write
197 .then(|| {
198 let root =
199 String::from_utf8(git(workspace, &["rev-parse", "--show-toplevel"])?).ok()?;
200 let root = PathBuf::from(root.trim());
201 let head = git(&root, &["rev-parse", "--verify", "HEAD"])
202 .and_then(|bytes| String::from_utf8(bytes).ok())
203 .map(|head| head.trim().to_string());
204 let dirty = status_paths(&root)?
205 .into_iter()
206 .map(|path| {
207 let path = normalize_claim_path(&path).ok()?;
208 fingerprint(&root, &path).map(|hash| (path, hash))
209 })
210 .collect::<Option<BTreeMap<_, _>>>()?;
211 Some(GitDeliveryBaseline { root, head, dirty })
212 })
213 .flatten();
214 Self {
215 baseline,
216 ..Self::default()
217 }
218 }
219
220 pub(super) fn changed_paths(&self, workspace: &Path) -> Option<BTreeSet<String>> {
221 let baseline = self.baseline.as_ref()?;
222 // Persisted evidence is data, never authority to inspect another tree
223 // or pass caller-controlled options to git after a restart.
224 let current_root =
225 String::from_utf8(git(workspace, &["rev-parse", "--show-toplevel"])?).ok()?;
226 let current_root = PathBuf::from(current_root.trim());
227 if !same_path_identity(&baseline.root, &current_root)
228 || baseline.dirty.len() > MAX_BASELINE_PATHS
229 || baseline
230 .dirty
231 .keys()
232 .any(|path| normalize_claim_path(path).as_ref() != Ok(path))
233 || baseline.head.as_ref().is_some_and(|head| {
234 !matches!(head.len(), 40 | 64) || !head.bytes().all(|byte| byte.is_ascii_hexdigit())
235 })
236 {
237 return None;
238 }
239 let mut candidates = status_paths(&baseline.root)?;
240 candidates.extend(baseline.dirty.keys().cloned());
241 if let Some(head) = baseline.head.as_deref() {
242 let mut args = vec!["diff"];
243 args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS);
244 args.extend(["--name-only", "-z", head, "HEAD", "--"]);
245 let output = git(&baseline.root, &args)?;
246 for path in output
247 .split(|byte| *byte == 0)
248 .filter(|path| !path.is_empty())
249 {
250 candidates.insert(std::str::from_utf8(path).ok()?.to_string());
251 }
252 }
253 // Git status paths are already repo-relative. Prefer them when the
254 // worker workspace is the git toplevel by identity; otherwise project
255 // through a shared canonicalize spelling. Always normalize separators
256 // so Windows `src\lib.rs` matches claim paths like `src/lib.rs`.
257 let workspace_root = workspace.canonicalize().ok()?;
258 let baseline_root = baseline.root.canonicalize().ok()?;
259 let workspace_is_repo_root = same_path_identity(&workspace_root, &baseline_root);
260 let mut changed = BTreeSet::new();
261 for path in candidates {
262 let Ok(path) = normalize_claim_path(&path) else {
263 continue;
264 };
265 if let Some(before) = baseline.dirty.get(&path)
266 && fingerprint(&baseline.root, &path).as_ref() == Some(before)
267 {
268 continue;
269 }
270 let relative = if workspace_is_repo_root {
271 path
272 } else {
273 let absolute = baseline_root.join(Path::new(&path));
274 let Ok(relative) = absolute.strip_prefix(&workspace_root) else {
275 continue;
276 };
277 let Ok(normalized) = normalize_claim_path(&relative.to_string_lossy()) else {
278 continue;
279 };
280 normalized
281 };
282 changed.insert(relative);
283 }
284 Some(changed)
285 }
286
287 /// Commit the worker's uncommitted changes as labeled salvage, and only
288 /// on an isolated worktree (#6194 item 4, #5529). Synchronous git reads
289 /// and writes: call under `spawn_blocking`, never under the manager
290 /// lock. `changed` is the `changed_paths` inventory the caller already
291 /// computed; the commit is skipped (not forced) when none of it is still
292 /// uncommitted, and every failure degrades to a note — never an error.
293 ///
294 /// Ownership is exact at every size (#6557 D02-11): only paths that are
295 /// both in `changed` and dirty now are staged and committed. A
296 /// pre-existing dirty file the worker never touched stays dirty, and a
297 /// file staged by someone else stays staged but outside the commit
298 /// (`git commit --only` with the owned pathspecs). Paths travel on stdin
299 /// as literal pathspecs, so a name like `*.rs` is never a glob and no
300 /// inventory size widens staging to the whole tree. A dirty tree larger
301 /// than the baseline bound is refused rather than guessed at.
302 pub(super) fn checkpoint_uncommitted(
303 &self,
304 changed: &BTreeSet<String>,
305 agent_id: &str,
306 cause: &str,
307 isolated_worktree: bool,
308 ) -> BudgetCheckpointOutcome {
309 use BudgetCheckpointOutcome::*;
310 if !isolated_worktree {
311 // A shared checkout may hold the parent's or a sibling's dirty
312 // files; auto-commit would sweep them into the checkpoint.
313 return SkippedNonIsolated;
314 }
315 if changed.is_empty() {
316 return Clean;
317 }
318 let Some(baseline) = self.baseline.as_ref() else {
319 return Failed {
320 reason: "no delivery baseline".to_string(),
321 };
322 };
323 let Some(dirty) = status_paths(&baseline.root) else {
324 return Failed {
325 reason: format!(
326 "git status failed or listed more than {MAX_BASELINE_PATHS} paths; nothing was staged"
327 ),
328 };
329 };
330 // Status spelling is what git matches; `changed` is normalized.
331 let owned: Vec<&str> = dirty
332 .iter()
333 .filter(|path| normalize_claim_path(path).is_ok_and(|path| changed.contains(&path)))
334 .map(String::as_str)
335 .collect();
336 if owned.is_empty() {
337 return Clean;
338 }
339 // `update-index` takes literal paths and stages every status shape:
340 // edits, new files (`--add`), and deletions or rename sources whose
341 // file is gone (`--remove`), where `git add <path>` would fail.
342 if let Err(reason) = git_captured_paths(
343 &baseline.root,
344 &["update-index", "--add", "--remove", "-z", "--stdin"],
345 &owned,
346 ) {
347 return Failed { reason };
348 }
349 let cause_short: String = cause
350 .lines()
351 .next()
352 .unwrap_or(cause)
353 .chars()
354 .take(120)
355 .collect();
356 let message = format!(
357 "checkpoint: {agent_id} ({cause_short}) - {} uncommitted file(s) at budget death; unreviewed salvage",
358 owned.len()
359 );
360 if let Err(reason) = git_captured_paths(
361 &baseline.root,
362 &[
363 "--literal-pathspecs",
364 "-c",
365 "user.name=Codewhale Subagent",
366 "-c",
367 "user.email=subagent@codewhale.invalid",
368 "commit",
369 "--quiet",
370 "--only",
371 "-m",
372 &message,
373 "--pathspec-from-file=-",
374 "--pathspec-file-nul",
375 ],
376 &owned,
377 ) {
378 return Failed { reason };
379 }
380 match git_captured(&baseline.root, &["rev-parse", "--short", "HEAD"]) {
381 Ok(sha) => Committed {
382 sha: sha.trim().to_string(),
383 },
384 Err(reason) => Failed { reason },
385 }
386 }
387 }
388
389 /// Outcome of the budget-death checkpoint commit.
390 pub(super) enum BudgetCheckpointOutcome {
391 /// Uncommitted work is now commit `sha` on the worker branch.
392 Committed { sha: String },
393 /// Nothing attributable to commit (clean tree, or the worker committed).
394 Clean,
395 /// Shared checkout: auto-commit would sweep up other writers' work.
396 SkippedNonIsolated,
397 /// Nothing was committed; files survive on disk.
398 Failed { reason: String },
399 }
400
401 fn same_path_identity(left: &Path, right: &Path) -> bool {
402 if left == right {
403 return true;
404 }
405 match (left.canonicalize(), right.canonicalize()) {
406 (Ok(left), Ok(right)) => left == right,
407 _ => false,
408 }
409 }
410
411 pub(super) fn declared_paths(
412 paths: &[String],
413 legacy: Option<&str>,
414 ) -> Result<Vec<String>, String> {
415 if paths.len() > MAX_DELIVERABLES {
416 return Err(format!(
417 "deliverables accepts at most {MAX_DELIVERABLES} paths"
418 ));
419 }
420 let mut paths = paths.to_vec();
421 if paths.is_empty()
422 && let Some(legacy) = legacy
423 && !legacy.chars().any(char::is_whitespace)
424 && (legacy.contains('/') || legacy.contains('.'))
425 {
426 paths.push(legacy.to_string());
427 }
428 let mut normalized = Vec::new();
429 for path in paths {
430 let path = normalize_claim_path(&path)?;
431 if path == "."
432 || path
433 .split('/')
434 .any(|part| crate::snapshot::is_git_metadata_name(std::ffi::OsStr::new(part)))
435 {
436 return Err("deliverables must name files outside git metadata".into());
437 }
438 if !normalized.contains(&path) {
439 normalized.push(path);
440 }
441 }
442 Ok(normalized)
443 }
444
445 pub(super) fn safe_deliverable_path(workspace: &Path, path: &str) -> Result<PathBuf, String> {
446 let path = declared_paths(&[path.to_string()], None)?
447 .pop()
448 .ok_or_else(|| "deliverable must name a file".to_string())?;
449 let root = workspace
450 .canonicalize()
451 .map_err(|_| "deliverable workspace is unavailable".to_string())?;
452 let mut resolved = root.clone();
453 for component in Path::new(&path).components() {
454 resolved.push(component);
455 match fs::symlink_metadata(&resolved) {
456 Ok(metadata) if metadata.file_type().is_symlink() => {
457 return Err("deliverable path traverses a symlink".into());
458 }
459 Ok(_) => {}
460 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
461 Err(_) => return Err("deliverable path cannot be inspected".into()),
462 }
463 }
464 if resolved == root || !resolved.starts_with(root) {
465 return Err("deliverable must be a file inside the worker workspace".into());
466 }
467 Ok(resolved)
468 }
469
470 pub(super) fn check_deliverable(workspace: &Path, path: &str, allowed: bool) -> DeliverableVerdict {
471 let mut verdict = DeliverableVerdict {
472 path: path.into(),
473 status: "out_of_scope".into(),
474 bytes: None,
475 };
476 if !allowed {
477 return verdict;
478 }
479 let resolved = match safe_deliverable_path(workspace, path) {
480 Ok(path) => path,
481 Err(_) => {
482 verdict.status = "invalid_path".into();
483 return verdict;
484 }
485 };
486 match fs::symlink_metadata(resolved) {
487 Ok(metadata) if metadata.is_file() => {
488 verdict.bytes = Some(metadata.len());
489 verdict.status = if metadata.len() == 0 {
490 "empty"
491 } else {
492 "present"
493 }
494 .into();
495 }
496 Ok(_) => verdict.status = "not_file".into(),
497 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
498 verdict.status = "missing".into()
499 }
500 Err(_) => verdict.status = "unreadable".into(),
501 }
502 verdict
503 }
504
505 fn citation(token: &str) -> bool {
506 // A citation may be sentence-final or either side of a Markdown link.
507 // Normalize punctuation only for citation detection; never rewrite a path.
508 token.split("](").any(|part| {
509 let part = part.trim_end_matches(|ch: char| {
510 matches!(
511 ch,
512 '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '\'' | '"' | '`'
513 )
514 });
515 let Some((_, line)) = part.rsplit_once(':') else {
516 return false;
517 };
518 let mut numbers = line.split('-');
519 let numeric =
520 |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit());
521 numbers.next().is_some_and(numeric)
522 && numbers.next().is_none_or(numeric)
523 && numbers.next().is_none()
524 })
525 }
526
527 pub(super) fn explicit_change_paths(summary: &str) -> BTreeSet<String> {
528 let mut paths = BTreeSet::new();
529 let mut in_changes = false;
530 for line in summary.lines() {
531 let line = line.trim();
532 let is_heading = line.starts_with('#');
533 let line = line.trim_start_matches('#').trim();
534 let lower = line.to_ascii_lowercase();
535 // SUBAGENT_OUTPUT_FORMAT uses a bare Markdown heading, with ordinary
536 // blank lines before its file bullets. That is an explicit declaration
537 // boundary just like the compatibility CHANGES: label.
538 if is_heading && lower == "changes" {
539 in_changes = true;
540 continue;
541 }
542 if line.is_empty() {
543 continue;
544 }
545 let declaration = ["changes:", "changed files:", "files changed:"]
546 .into_iter()
547 .find(|prefix| lower.starts_with(prefix));
548 let content = if let Some(prefix) = declaration {
549 in_changes = true;
550 &line[prefix.len()..]
551 } else if in_changes && (line.starts_with('-') || line.starts_with('*')) {
552 line
553 } else {
554 in_changes = false;
555 continue;
556 };
557 if matches!(
558 content
559 .trim()
560 .trim_end_matches('.')
561 .to_ascii_lowercase()
562 .as_str(),
563 "none" | "no files changed" | "no changes"
564 ) {
565 in_changes = false;
566 continue;
567 }
568 let content = content.trim();
569 let mut remaining = content
570 .strip_prefix("- ")
571 .or_else(|| content.strip_prefix("* "))
572 .unwrap_or(content);
573 // File declarations lead with paths. Stop when their description
574 // starts instead of interpreting sentence-final prose ("parsing.")
575 // or a later reference ("see notes.md") as another claimed edit.
576 while !remaining.is_empty() {
577 remaining = remaining
578 .trim_start_matches(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';'));
579 let Some(first) = remaining.chars().next() else {
580 break;
581 };
582 let quoted = matches!(first, '`' | '"' | '\'');
583 let (token, rest) = if quoted {
584 let Some((token, rest)) = remaining[1..].split_once(first) else {
585 break;
586 };
587 (token, rest)
588 } else {
589 let end = remaining
590 .find(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';'))
591 .unwrap_or(remaining.len());
592 remaining.split_at(end)
593 };
594 remaining = rest;
595 if citation(token) || token.contains("://") {
596 break;
597 }
598 let token = if quoted {
599 token
600 } else {
601 token
602 .trim_matches(|ch: char| matches!(ch, '(' | ')' | '[' | ']' | '*' | '-'))
603 .trim_end_matches(['.', ':', '!', '?'])
604 };
605 let token = token.split_once("](").map_or(token, |(label, _)| label);
606 if !token.contains('/') && !token.contains('.') {
607 break;
608 }
609 let Ok(path) = normalize_claim_path(token) else {
610 break;
611 };
612 if path == "." {
613 break;
614 }
615 paths.insert(path);
616 }
617 }
618 paths
619 }
620
621 pub(super) fn verify_changes(
622 summary: &str,
623 write_capable: bool,
624 evidence: &DeliveryEvidence,
625 changed: Option<&BTreeSet<String>>,
626 declared_outputs: &BTreeSet<String>,
627 ) -> Option<AgentRunVerificationSummary> {
628 if !write_capable {
629 return None;
630 }
631 let claimed = explicit_change_paths(summary);
632 let missing = changed
633 .map(|changed| claimed.difference(changed).cloned().collect::<Vec<_>>())
634 .unwrap_or_default();
635 // A path changing inside a writable scope proves neither the actor nor a
636 // child write. External tools and people can edit the same checkout, so only
637 // successful bounded write receipts can support an undeclared-write claim.
638 let undeclared = evidence
639 .observed_writes
640 .iter()
641 .filter(|path| {
642 changed.is_none_or(|changed| changed.contains(*path))
643 && !claimed.contains(*path)
644 && !declared_outputs.contains(*path)
645 })
646 .cloned()
647 .collect::<Vec<_>>();
648 if missing.is_empty() && undeclared.is_empty() {
649 return None;
650 }
651 Some(AgentRunVerificationSummary {
652 status: "claim_mismatch".into(),
653 summary: format!(
654 "Compared with the workspace at spawn: declared but unchanged: {missing:?}; observed changes without a change declaration: {undeclared:?}. Inspect the worker receipt."
655 ),
656 deliverables: Vec::new(),
657 })
658 }
659
660 /// Everything delivery verification needs, snapshotted under a read lock.
661 /// `allowed[i]` is the write-scope verdict for `deliverables[i]`. The compute
662 /// half runs in `spawn_blocking` with no manager lock held (#6210).
663 #[derive(Debug, Clone)]
664 pub(super) struct DeliveryVerificationInputs {
665 pub evidence: DeliveryEvidence,
666 pub workspace: PathBuf,
667 pub result_text: String,
668 pub write_perm: bool,
669 pub deliverables: Vec<String>,
670 pub allowed: Vec<bool>,
671 /// The worker ran in its own isolated worktree and settled in a state that
672 /// cannot be resumed there; remove the worktree if it changed nothing.
673 pub remove_worktree_if_unchanged: bool,
674 }
675
676 /// Pure compute half of worker delivery verification: the git trio +
677 /// fingerprints (`changed_paths`), claim comparison, and per-deliverable
678 /// presence checks. Runs off the manager lock; the caller stores the summary.
679 pub(super) fn compute_delivery_verification(
680 inputs: &DeliveryVerificationInputs,
681 ) -> AgentRunVerificationSummary {
682 let changed = inputs.evidence.changed_paths(&inputs.workspace);
683 let mut verification = verify_changes(
684 &inputs.result_text,
685 inputs.write_perm,
686 &inputs.evidence,
687 changed.as_ref(),
688 &inputs.deliverables.iter().cloned().collect(),
689 )
690 .unwrap_or_else(default_agent_run_verification);
691 verification.deliverables = inputs
692 .deliverables
693 .iter()
694 .zip(inputs.allowed.iter())
695 .map(|(path, allowed)| check_deliverable(&inputs.workspace, path, *allowed))
696 .collect();
697 let missing = verification
698 .deliverables
699 .iter()
700 .filter(|verdict| verdict.status != "present")
701 .map(|verdict| format!("{} ({})", verdict.path, verdict.status))
702 .collect::<Vec<_>>();
703 if !missing.is_empty() {
704 let prior = if verification.status == "claim_mismatch" {
705 format!(" {}", verification.summary)
706 } else {
707 String::new()
708 };
709 verification.status = "deliverable_missing".to_string();
710 verification.summary = format!(
711 "Declared deliverables not produced as non-empty files in the worker write scope: {}.{prior}",
712 missing.join(", ")
713 );
714 } else if !inputs.deliverables.is_empty() && verification.status == "self_report_only" {
715 verification.status = "deliverables_present".to_string();
716 verification.summary = "Declared files exist and are non-empty inside the worker write scope; their contents remain a worker self-report.".to_string();
717 }
718 verification
719 }
720
720 lines RUST