| 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, ¤t_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 |