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