Skip to content

Commit 6ee7660

Browse files
author
Alex
committed
fix(orchestrator): add worktree ownership manifest gate to sweep Refs #1731
Add WorktreeManifest sentinel written at worktree creation time. sweep_one and adf-cleanup.sh now require a valid manifest before deleting any directory, regardless of naming convention. - scope.rs: Add WorktreeManifest struct with read/write/validate. write_test_manifest helper for tests. sweep_one skips entries without valid manifest (counted as no_manifest_skipped). - adf-cleanup.sh: valid_manifest() gate before /bin/rm -rf. - test_adf_cleanup.sh: write manifests for test worktrees. - Updated all sweep unit tests to use write_test_manifest. - SweepReport gains no_manifest_skipped field.
1 parent 1053b84 commit 6ee7660

3 files changed

Lines changed: 225 additions & 13 deletions

File tree

crates/terraphim_orchestrator/src/scope.rs

Lines changed: 172 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,102 @@ use uuid::Uuid;
66

77
use crate::worktree_guard::WorktreeGuard;
88

9+
/// Filename of the ownership manifest written at the root of every ADF
10+
/// worktree. Presence of this file with valid contents is the gate for
11+
/// cleanup: sweep only deletes entries carrying this sentinel.
12+
pub const WORKTREE_MANIFEST_FILENAME: &str = ".adf-worktree-manifest.json";
13+
14+
/// Ownership manifest stored at the root of each ADF worktree.
15+
///
16+
/// Existence of this file with matching repo and path fields is the
17+
/// single gate for sweep/cleanup. Directories without a valid manifest
18+
/// are preserved regardless of name prefix or location.
19+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20+
pub struct WorktreeManifest {
21+
/// Schema version for forward compatibility.
22+
pub version: u32,
23+
/// Git repository this worktree belongs to.
24+
pub repo_path: String,
25+
/// Absolute path of this worktree (self-referential).
26+
pub worktree_path: String,
27+
/// Name of the agent or component that created this worktree.
28+
pub creator: String,
29+
/// Session or correlation ID linking this worktree to its task.
30+
pub session_id: String,
31+
/// Process ID that performed the `git worktree add`.
32+
pub pid: u32,
33+
/// ISO-8601 timestamp of creation.
34+
pub created_at: String,
35+
}
36+
37+
impl WorktreeManifest {
38+
/// Current schema version. Increment when the struct changes
39+
/// incompatibly.
40+
pub const CURRENT_VERSION: u32 = 1;
41+
42+
/// Write a manifest to `dir / WORKTREE_MANIFEST_FILENAME`.
43+
pub fn write_to_dir(&self, dir: &Path) -> Result<(), std::io::Error> {
44+
let path = dir.join(WORKTREE_MANIFEST_FILENAME);
45+
let json = serde_json::to_string_pretty(self)
46+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
47+
std::fs::write(&path, json)?;
48+
debug!(path = %path.display(), "worktree manifest written");
49+
Ok(())
50+
}
51+
52+
/// Read and validate a manifest from a directory.
53+
///
54+
/// Returns `None` if the file is absent, unreadable, unparseable,
55+
/// or fails validation (wrong repo, wrong path, or future version).
56+
pub fn read_from_dir(dir: &Path) -> Option<Self> {
57+
let path = dir.join(WORKTREE_MANIFEST_FILENAME);
58+
let bytes = std::fs::read(&path).ok()?;
59+
let m: WorktreeManifest = serde_json::from_slice(&bytes).ok()?;
60+
if m.version > Self::CURRENT_VERSION {
61+
warn!(
62+
path = %path.display(),
63+
manifest_version = m.version,
64+
current_version = Self::CURRENT_VERSION,
65+
"worktree manifest version too new, skipping"
66+
);
67+
return None;
68+
}
69+
Some(m)
70+
}
71+
72+
/// Check that the manifest's embedded paths match the expected
73+
/// repository and the actual directory on disk.
74+
pub fn validate(&self, expected_repo: &Path, dir_on_disk: &Path) -> bool {
75+
if self.repo_path != expected_repo.to_string_lossy() {
76+
warn!(
77+
manifest_repo = %self.repo_path,
78+
expected_repo = %expected_repo.display(),
79+
"worktree manifest repo mismatch"
80+
);
81+
return false;
82+
}
83+
if self.worktree_path != dir_on_disk.to_string_lossy() {
84+
warn!(
85+
manifest_path = %self.worktree_path,
86+
dir_on_disk = %dir_on_disk.display(),
87+
"worktree manifest path mismatch"
88+
);
89+
return false;
90+
}
91+
true
92+
}
93+
94+
/// Convenience: read from `dir` and validate against `expected_repo`.
95+
pub fn read_valid(dir: &Path, expected_repo: &Path) -> Option<Self> {
96+
let m = Self::read_from_dir(dir)?;
97+
if m.validate(expected_repo, dir) {
98+
Some(m)
99+
} else {
100+
None
101+
}
102+
}
103+
}
104+
9105
/// Directory-name prefix for compound-review worktrees.
10106
///
11107
/// Single source of truth referenced by:
@@ -315,6 +411,27 @@ impl WorktreeManager {
315411
}
316412

317413
info!(name = %name, path = %worktree_path.display(), "worktree created");
414+
415+
// Write ownership manifest so sweep can safely identify this
416+
// worktree as ADF-managed. Best-effort; a missing or invalid
417+
// manifest inhibits cleanup rather than breaking the worktree.
418+
let manifest = WorktreeManifest {
419+
version: WorktreeManifest::CURRENT_VERSION,
420+
repo_path: self.repo_path.to_string_lossy().to_string(),
421+
worktree_path: worktree_path.to_string_lossy().to_string(),
422+
creator: "orchestrator".to_string(),
423+
session_id: name.to_string(),
424+
pid: std::process::id(),
425+
created_at: chrono::Utc::now().to_rfc3339(),
426+
};
427+
if let Err(e) = manifest.write_to_dir(&worktree_path) {
428+
warn!(
429+
path = %worktree_path.display(),
430+
error = %e,
431+
"failed to write worktree manifest; cleanup will skip this entry"
432+
);
433+
}
434+
318435
Ok(WorktreeGuard::for_managed(&self.repo_path, worktree_path))
319436
}
320437

@@ -538,6 +655,7 @@ impl WorktreeManager {
538655
swept_count = report.swept_count,
539656
failed_count = report.failed_count,
540657
root_owned_skipped = report.root_owned_skipped,
658+
no_manifest_skipped = report.no_manifest_skipped,
541659
prune_succeeded = report.prune_succeeded,
542660
duration_ms = report.duration_ms,
543661
backlog_count = report.swept_count + report.root_owned_skipped,
@@ -548,12 +666,36 @@ impl WorktreeManager {
548666

549667
/// Remove one worktree path, updating `report` in place.
550668
///
669+
/// Before deletion, checks for a valid [`WorktreeManifest`]. If the
670+
/// manifest is absent, invalid, or mismatched, the directory is
671+
/// preserved regardless of naming convention. This prevents
672+
/// accidental deletion of non-ADF directories that happen to share
673+
/// the `review-` prefix or live under `/tmp/adf-worktrees`.
674+
///
551675
/// Tries `git worktree remove --force` first so the git admin
552676
/// registry stays in sync; falls back to `remove_dir_all` on
553677
/// non-zero exit. Permission-denied during the fallback path is
554678
/// counted as `root_owned_skipped` (Layer 3 territory) rather
555679
/// than a hard failure.
556680
fn sweep_one(&self, path: &Path, report: &mut SweepReport) {
681+
// Reject entries that do not carry a valid manifest.
682+
let manifest = match WorktreeManifest::read_valid(path, &self.repo_path) {
683+
Some(m) => m,
684+
None => {
685+
warn!(
686+
path = %path.display(),
687+
"sweep_stale skipping directory without valid ADF manifest"
688+
);
689+
report.no_manifest_skipped += 1;
690+
return;
691+
}
692+
};
693+
debug!(
694+
path = %path.display(),
695+
creator = %manifest.creator,
696+
session_id = %manifest.session_id,
697+
"sweep_stale found valid manifest, proceeding with removal"
698+
);
557699
let status = std::process::Command::new("git")
558700
.arg("-C")
559701
.arg(&self.repo_path)
@@ -621,6 +763,10 @@ pub struct SweepReport {
621763
/// permission. These belong to Layer 3 (`adf-cleanup.sh` run as
622764
/// root via `ExecStartPre`).
623765
pub root_owned_skipped: usize,
766+
/// Number of entries skipped because no valid ADF worktree
767+
/// manifest was found in the directory. Protects non-ADF data
768+
/// from accidental deletion.
769+
pub no_manifest_skipped: usize,
624770
/// Whether `git worktree prune --verbose` exited zero. False
625771
/// implies the git admin registry under `<repo>/.git/worktrees`
626772
/// may still hold dangling entries; the next sweep will retry.
@@ -888,6 +1034,22 @@ mod tests {
8881034
(temp_dir, repo_path)
8891035
}
8901036

1037+
/// Write a valid ADF worktree manifest into `dir` for testing.
1038+
/// Used by sweep tests to ensure test directories are recognised
1039+
/// as ADF-managed worktrees.
1040+
fn write_test_manifest(dir: &Path, repo_path: &Path) {
1041+
let manifest = WorktreeManifest {
1042+
version: WorktreeManifest::CURRENT_VERSION,
1043+
repo_path: repo_path.to_string_lossy().to_string(),
1044+
worktree_path: dir.to_string_lossy().to_string(),
1045+
creator: "test".to_string(),
1046+
session_id: "test-session".to_string(),
1047+
pid: std::process::id(),
1048+
created_at: chrono::Utc::now().to_rfc3339(),
1049+
};
1050+
manifest.write_to_dir(dir).expect("write test manifest");
1051+
}
1052+
8911053
#[tokio::test]
8921054
async fn test_create_worktree() {
8931055
let (_temp_dir, repo_path) = setup_git_repo();
@@ -1057,7 +1219,8 @@ mod tests {
10571219
// Seed three `review-*` directories as plain dirs (not real
10581220
// worktrees -- sweep_one falls back to remove_dir_all when git
10591221
// refuses an unregistered path, which is exactly the
1060-
// residue-after-SIGKILL shape).
1222+
// residue-after-SIGKILL shape). Each must carry a valid
1223+
// manifest or the sweep will preserve it.
10611224
let (_temp_dir, repo_path) = setup_git_repo();
10621225
let base = repo_path.join(".worktrees");
10631226
std::fs::create_dir_all(&base).unwrap();
@@ -1066,6 +1229,7 @@ mod tests {
10661229
let dir = base.join(format!("{}{}", WORKTREE_REVIEW_PREFIX, i));
10671230
std::fs::create_dir_all(&dir).unwrap();
10681231
std::fs::write(dir.join("dummy.txt"), "residue").unwrap();
1232+
write_test_manifest(&dir, &repo_path);
10691233
}
10701234

10711235
let manager = WorktreeManager::with_base(&repo_path, &base);
@@ -1074,7 +1238,7 @@ mod tests {
10741238
assert_eq!(report.swept_count, 3, "all three review dirs swept");
10751239
assert_eq!(report.failed_count, 0);
10761240
assert_eq!(report.root_owned_skipped, 0);
1077-
assert!(report.prune_succeeded);
1241+
assert_eq!(report.no_manifest_skipped, 0);
10781242

10791243
for i in 0..3 {
10801244
let dir = base.join(format!("{}{}", WORKTREE_REVIEW_PREFIX, i));
@@ -1084,8 +1248,9 @@ mod tests {
10841248

10851249
#[test]
10861250
fn test_sweep_stale_preserves_non_review_prefix() {
1087-
// Only `review-` prefixed entries are swept from worktree_base.
1088-
// Sibling directories under the same base must be preserved.
1251+
// Only `review-` prefixed entries with valid manifests are
1252+
// swept from worktree_base. `keep-me` lacks BOTH a review
1253+
// prefix AND a manifest, so it must survive.
10891254
let (_temp_dir, repo_path) = setup_git_repo();
10901255
let base = repo_path.join(".worktrees");
10911256
std::fs::create_dir_all(&base).unwrap();
@@ -1095,6 +1260,7 @@ mod tests {
10951260
std::fs::create_dir_all(&review_dir).unwrap();
10961261
std::fs::create_dir_all(&keep_dir).unwrap();
10971262
std::fs::write(keep_dir.join("important.txt"), "data").unwrap();
1263+
write_test_manifest(&review_dir, &repo_path);
10981264

10991265
let manager = WorktreeManager::with_base(&repo_path, &base);
11001266
let report = manager.sweep_stale(&[]);
@@ -1129,6 +1295,7 @@ mod tests {
11291295
fn test_sweep_stale_extra_roots_no_prefix_filter() {
11301296
// Entries under extra_roots are swept regardless of prefix --
11311297
// the per-agent root convention has no naming convention.
1298+
// Each must carry a valid manifest.
11321299
let (_temp_dir, repo_path) = setup_git_repo();
11331300
let base = repo_path.join(".worktrees");
11341301
std::fs::create_dir_all(&base).unwrap();
@@ -1140,6 +1307,7 @@ mod tests {
11401307
let agent_dir = extra.join("agent-alpha");
11411308
std::fs::create_dir_all(&agent_dir).unwrap();
11421309
std::fs::write(agent_dir.join("scratch.txt"), "tmp").unwrap();
1310+
write_test_manifest(&agent_dir, &repo_path);
11431311

11441312
let manager = WorktreeManager::with_base(&repo_path, &base);
11451313
let report = manager.sweep_stale(std::slice::from_ref(&extra));

scripts/adf-setup/adf-cleanup.sh

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
# Runs as root so it can reclaim worktree contents owned by
66
# sub-process container builds and other elevated agents.
77
#
8-
# Cross-reference: WORKTREE_REVIEW_PREFIX in
9-
# crates/terraphim_orchestrator/src/scope.rs. The literal "review-"
10-
# below must stay in sync with that constant.
8+
# SAFETY: Only deletes directories that contain a valid
9+
# .adf-worktree-manifest.json file matching this repository.
10+
# Unknown directories are preserved regardless of naming convention.
11+
#
12+
# Cross-reference: WORKTREE_REVIEW_PREFIX and WORKTREE_MANIFEST_FILENAME in
13+
# crates/terraphim_orchestrator/src/scope.rs.
1114

1215
set -eu
1316
umask 022
@@ -18,12 +21,35 @@ ADF_AGENT_TMP_ROOT="${ADF_AGENT_TMP_ROOT:-/tmp/adf-worktrees}"
1821

1922
swept=0
2023
failed=0
24+
skipped=0
25+
26+
MANIFEST=".adf-worktree-manifest.json"
27+
28+
# Check that a directory carries a valid ADF worktree manifest.
29+
# Validates repo_path and worktree_path fields against reality.
30+
valid_manifest() {
31+
dir="$1"
32+
mf="${dir}/${MANIFEST}"
33+
[ -f "$mf" ] || return 1
34+
# Extract repo_path and worktree_path from the JSON manifest.
35+
# POSIX grep + sed fallback; jq is not guaranteed on minimal hosts.
36+
mf_repo=$(grep -o '"repo_path"[[:space:]]*:[[:space:]]*"[^"]*"' "$mf" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
37+
mf_path=$(grep -o '"worktree_path"[[:space:]]*:[[:space:]]*"[^"]*"' "$mf" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
38+
[ "$mf_repo" = "$ADF_REPO_PATH" ] || return 1
39+
[ "$mf_path" = "$dir" ] || return 1
40+
return 0
41+
}
2142

2243
sweep_one() {
2344
target="$1"
2445
if [ ! -e "$target" ]; then
2546
return 0
2647
fi
48+
if ! valid_manifest "$target"; then
49+
printf 'adf-cleanup: skipping %s (no valid ADF manifest)\n' "$target" >&2
50+
skipped=$((skipped + 1))
51+
return 0
52+
fi
2753
if git -C "$ADF_REPO_PATH" worktree remove --force "$target" >/dev/null 2>&1; then
2854
swept=$((swept + 1))
2955
return 0
@@ -56,7 +82,7 @@ fi
5682
# 3. Reconcile git's admin registry. Failure here is not fatal.
5783
git -C "$ADF_REPO_PATH" worktree prune --verbose 2>&1 || true
5884

59-
printf 'adf-cleanup: swept=%d failed=%d repo=%s\n' \
60-
"$swept" "$failed" "$ADF_REPO_PATH"
85+
printf 'adf-cleanup: swept=%d failed=%d skipped=%d repo=%s\n' \
86+
"$swept" "$failed" "$skipped" "$ADF_REPO_PATH"
6187

6288
exit 0

scripts/adf-setup/tests/test_adf_cleanup.sh

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
#!/bin/sh
22
# test_adf_cleanup.sh -- POSIX shell driver for adf-cleanup.sh.
33
#
4-
# Seeds three review-* worktrees plus a keep-me/ directory in a
5-
# disposable git repo, runs the sweep script, and asserts that
6-
# review-* entries are removed while keep-me/ is preserved. A
7-
# second run verifies idempotency.
4+
# Seeds three review-* worktrees (with ADF manifests) plus a keep-me/
5+
# directory (without manifest) in a disposable git repo, runs the
6+
# sweep script, and asserts that review-* entries are removed while
7+
# keep-me/ is preserved. A second run verifies idempotency.
88

99
set -eu
1010

@@ -21,10 +21,28 @@ git -C "$REPO" -c init.defaultBranch=main init -q
2121
git -C "$REPO" -c user.email=test@example.com -c user.name=Test \
2222
commit --allow-empty -m "seed" -q
2323

24+
# Worktree manifest helper: writes a valid .adf-worktree-manifest.json
25+
# into the target directory.
26+
write_manifest() {
27+
dir="$1"
28+
cat > "${dir}/.adf-worktree-manifest.json" <<MANIFEST_EOF
29+
{
30+
"version": 1,
31+
"repo_path": "${REPO}",
32+
"worktree_path": "${dir}",
33+
"creator": "test",
34+
"session_id": "test-session",
35+
"pid": $$,
36+
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
37+
}
38+
MANIFEST_EOF
39+
}
40+
2441
mkdir -p "$WT_ROOT/keep-me"
2542

2643
for i in 1 2 3; do
2744
git -C "$REPO" worktree add -q "${WT_ROOT}/review-test-${i}" HEAD
45+
write_manifest "${WT_ROOT}/review-test-${i}"
2846
done
2947

3048
[ -d "${WT_ROOT}/review-test-1" ] || { echo "setup failed"; exit 1; }

0 commit comments

Comments
 (0)