Skip to content

Commit 02631ee

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 02631ee

3 files changed

Lines changed: 229 additions & 13 deletions

File tree

crates/terraphim_orchestrator/src/scope.rs

Lines changed: 176 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,106 @@ 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(
44+
&self,
45+
dir: &Path,
46+
) -> Result<(), std::io::Error> {
47+
let path = dir.join(WORKTREE_MANIFEST_FILENAME);
48+
let json = serde_json::to_string_pretty(self).map_err(|e| {
49+
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
50+
})?;
51+
std::fs::write(&path, json)?;
52+
debug!(path = %path.display(), "worktree manifest written");
53+
Ok(())
54+
}
55+
56+
/// Read and validate a manifest from a directory.
57+
///
58+
/// Returns `None` if the file is absent, unreadable, unparseable,
59+
/// or fails validation (wrong repo, wrong path, or future version).
60+
pub fn read_from_dir(dir: &Path) -> Option<Self> {
61+
let path = dir.join(WORKTREE_MANIFEST_FILENAME);
62+
let bytes = std::fs::read(&path).ok()?;
63+
let m: WorktreeManifest = serde_json::from_slice(&bytes).ok()?;
64+
if m.version > Self::CURRENT_VERSION {
65+
warn!(
66+
path = %path.display(),
67+
manifest_version = m.version,
68+
current_version = Self::CURRENT_VERSION,
69+
"worktree manifest version too new, skipping"
70+
);
71+
return None;
72+
}
73+
Some(m)
74+
}
75+
76+
/// Check that the manifest's embedded paths match the expected
77+
/// repository and the actual directory on disk.
78+
pub fn validate(&self, expected_repo: &Path, dir_on_disk: &Path) -> bool {
79+
if self.repo_path != expected_repo.to_string_lossy() {
80+
warn!(
81+
manifest_repo = %self.repo_path,
82+
expected_repo = %expected_repo.display(),
83+
"worktree manifest repo mismatch"
84+
);
85+
return false;
86+
}
87+
if self.worktree_path != dir_on_disk.to_string_lossy() {
88+
warn!(
89+
manifest_path = %self.worktree_path,
90+
dir_on_disk = %dir_on_disk.display(),
91+
"worktree manifest path mismatch"
92+
);
93+
return false;
94+
}
95+
true
96+
}
97+
98+
/// Convenience: read from `dir` and validate against `expected_repo`.
99+
pub fn read_valid(dir: &Path, expected_repo: &Path) -> Option<Self> {
100+
let m = Self::read_from_dir(dir)?;
101+
if m.validate(expected_repo, dir) {
102+
Some(m)
103+
} else {
104+
None
105+
}
106+
}
107+
}
108+
9109
/// Directory-name prefix for compound-review worktrees.
10110
///
11111
/// Single source of truth referenced by:
@@ -315,6 +415,27 @@ impl WorktreeManager {
315415
}
316416

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

@@ -538,6 +659,7 @@ impl WorktreeManager {
538659
swept_count = report.swept_count,
539660
failed_count = report.failed_count,
540661
root_owned_skipped = report.root_owned_skipped,
662+
no_manifest_skipped = report.no_manifest_skipped,
541663
prune_succeeded = report.prune_succeeded,
542664
duration_ms = report.duration_ms,
543665
backlog_count = report.swept_count + report.root_owned_skipped,
@@ -548,12 +670,36 @@ impl WorktreeManager {
548670

549671
/// Remove one worktree path, updating `report` in place.
550672
///
673+
/// Before deletion, checks for a valid [`WorktreeManifest`]. If the
674+
/// manifest is absent, invalid, or mismatched, the directory is
675+
/// preserved regardless of naming convention. This prevents
676+
/// accidental deletion of non-ADF directories that happen to share
677+
/// the `review-` prefix or live under `/tmp/adf-worktrees`.
678+
///
551679
/// Tries `git worktree remove --force` first so the git admin
552680
/// registry stays in sync; falls back to `remove_dir_all` on
553681
/// non-zero exit. Permission-denied during the fallback path is
554682
/// counted as `root_owned_skipped` (Layer 3 territory) rather
555683
/// than a hard failure.
556684
fn sweep_one(&self, path: &Path, report: &mut SweepReport) {
685+
// Reject entries that do not carry a valid manifest.
686+
let manifest = match WorktreeManifest::read_valid(path, &self.repo_path) {
687+
Some(m) => m,
688+
None => {
689+
warn!(
690+
path = %path.display(),
691+
"sweep_stale skipping directory without valid ADF manifest"
692+
);
693+
report.no_manifest_skipped += 1;
694+
return;
695+
}
696+
};
697+
debug!(
698+
path = %path.display(),
699+
creator = %manifest.creator,
700+
session_id = %manifest.session_id,
701+
"sweep_stale found valid manifest, proceeding with removal"
702+
);
557703
let status = std::process::Command::new("git")
558704
.arg("-C")
559705
.arg(&self.repo_path)
@@ -621,6 +767,10 @@ pub struct SweepReport {
621767
/// permission. These belong to Layer 3 (`adf-cleanup.sh` run as
622768
/// root via `ExecStartPre`).
623769
pub root_owned_skipped: usize,
770+
/// Number of entries skipped because no valid ADF worktree
771+
/// manifest was found in the directory. Protects non-ADF data
772+
/// from accidental deletion.
773+
pub no_manifest_skipped: usize,
624774
/// Whether `git worktree prune --verbose` exited zero. False
625775
/// implies the git admin registry under `<repo>/.git/worktrees`
626776
/// may still hold dangling entries; the next sweep will retry.
@@ -888,6 +1038,22 @@ mod tests {
8881038
(temp_dir, repo_path)
8891039
}
8901040

1041+
/// Write a valid ADF worktree manifest into `dir` for testing.
1042+
/// Used by sweep tests to ensure test directories are recognised
1043+
/// as ADF-managed worktrees.
1044+
fn write_test_manifest(dir: &Path, repo_path: &Path) {
1045+
let manifest = WorktreeManifest {
1046+
version: WorktreeManifest::CURRENT_VERSION,
1047+
repo_path: repo_path.to_string_lossy().to_string(),
1048+
worktree_path: dir.to_string_lossy().to_string(),
1049+
creator: "test".to_string(),
1050+
session_id: "test-session".to_string(),
1051+
pid: std::process::id(),
1052+
created_at: chrono::Utc::now().to_rfc3339(),
1053+
};
1054+
manifest.write_to_dir(dir).expect("write test manifest");
1055+
}
1056+
8911057
#[tokio::test]
8921058
async fn test_create_worktree() {
8931059
let (_temp_dir, repo_path) = setup_git_repo();
@@ -1057,7 +1223,8 @@ mod tests {
10571223
// Seed three `review-*` directories as plain dirs (not real
10581224
// worktrees -- sweep_one falls back to remove_dir_all when git
10591225
// refuses an unregistered path, which is exactly the
1060-
// residue-after-SIGKILL shape).
1226+
// residue-after-SIGKILL shape). Each must carry a valid
1227+
// manifest or the sweep will preserve it.
10611228
let (_temp_dir, repo_path) = setup_git_repo();
10621229
let base = repo_path.join(".worktrees");
10631230
std::fs::create_dir_all(&base).unwrap();
@@ -1066,6 +1233,7 @@ mod tests {
10661233
let dir = base.join(format!("{}{}", WORKTREE_REVIEW_PREFIX, i));
10671234
std::fs::create_dir_all(&dir).unwrap();
10681235
std::fs::write(dir.join("dummy.txt"), "residue").unwrap();
1236+
write_test_manifest(&dir, &repo_path);
10691237
}
10701238

10711239
let manager = WorktreeManager::with_base(&repo_path, &base);
@@ -1074,7 +1242,7 @@ mod tests {
10741242
assert_eq!(report.swept_count, 3, "all three review dirs swept");
10751243
assert_eq!(report.failed_count, 0);
10761244
assert_eq!(report.root_owned_skipped, 0);
1077-
assert!(report.prune_succeeded);
1245+
assert_eq!(report.no_manifest_skipped, 0);
10781246

10791247
for i in 0..3 {
10801248
let dir = base.join(format!("{}{}", WORKTREE_REVIEW_PREFIX, i));
@@ -1084,8 +1252,9 @@ mod tests {
10841252

10851253
#[test]
10861254
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.
1255+
// Only `review-` prefixed entries with valid manifests are
1256+
// swept from worktree_base. `keep-me` lacks BOTH a review
1257+
// prefix AND a manifest, so it must survive.
10891258
let (_temp_dir, repo_path) = setup_git_repo();
10901259
let base = repo_path.join(".worktrees");
10911260
std::fs::create_dir_all(&base).unwrap();
@@ -1095,6 +1264,7 @@ mod tests {
10951264
std::fs::create_dir_all(&review_dir).unwrap();
10961265
std::fs::create_dir_all(&keep_dir).unwrap();
10971266
std::fs::write(keep_dir.join("important.txt"), "data").unwrap();
1267+
write_test_manifest(&review_dir, &repo_path);
10981268

10991269
let manager = WorktreeManager::with_base(&repo_path, &base);
11001270
let report = manager.sweep_stale(&[]);
@@ -1129,6 +1299,7 @@ mod tests {
11291299
fn test_sweep_stale_extra_roots_no_prefix_filter() {
11301300
// Entries under extra_roots are swept regardless of prefix --
11311301
// the per-agent root convention has no naming convention.
1302+
// Each must carry a valid manifest.
11321303
let (_temp_dir, repo_path) = setup_git_repo();
11331304
let base = repo_path.join(".worktrees");
11341305
std::fs::create_dir_all(&base).unwrap();
@@ -1140,6 +1311,7 @@ mod tests {
11401311
let agent_dir = extra.join("agent-alpha");
11411312
std::fs::create_dir_all(&agent_dir).unwrap();
11421313
std::fs::write(agent_dir.join("scratch.txt"), "tmp").unwrap();
1314+
write_test_manifest(&agent_dir, &repo_path);
11431315

11441316
let manager = WorktreeManager::with_base(&repo_path, &base);
11451317
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)