返回 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 /// 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, &current_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
676 lines RUST