1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
//! Content gates on decommission's two `remove_dir_all` routes (#8663).
//!
//! Why: every `git worktree remove` route refuses on dirty files, unpushed
//! commits and kept gitignored output (#4091, #8534). Two routes delete with
//! `remove_dir_all` instead and asked none of it: decommission of an SM-owned
//! workspace (which can be a git worktree, #5949), and the shared remover's
//! fallback for a directory git has disowned. One decommission call could
//! delete an agent's `results/` and its unpushed commits.
//! What: [`remove_owned_workspace`] runs [`owned_workspace_keep_reason`] and the
//! delete inside one audited blocking task; [`unclaimed_directory_blocks_removal`]
//! gates the disowned-directory fallback. Every failed check keeps the
//! directory, and #9444: so does a live process standing in an owned one.
//! Test: `decommission_owned_tests`.
use std::path::Path;
use tracing::{info, warn};
use super::decommission::WorktreeRemoval;
use super::decommission_force::{
ProvisioningDirt, WorkspaceVerdict, WorktreeKind, dirty_entries, force_blocker,
is_provisioning_entry, kept_for_dirt, lock_blocker, worktree_kind,
};
use super::manager::ManagedError;
use super::provisioning_ledger;
use super::record::ManagedSessionId;
use super::worktree_ignored_output::{
ignored_output_refusal, kept_unversioned_content, unversioned_content_refusal,
};
use super::worktree_liveness::process_holding;
use super::worktree_safety::{DirtyWorktreePolicy, inspect_dirt_excusing, is_worktree_root};
/// Why an SM-owned workspace must be kept, or `None` when it may be deleted
/// (#8663).
///
/// Why: `workspace_owned` says tm created the directory, not that nothing in
/// it is worth keeping.
/// What: a directory that is its own git worktree root gets the worktree
/// guard. [`worktree_kind`] must prove it a linked worktree or a main
/// checkout; a probe error keeps it under either policy. A linked worktree
/// that `git worktree lock` protects is kept under either policy
/// ([`lock_blocker`]). Then [`inspect_dirt_excusing`] (dirty files, unpushed
/// commits, nested repositories), excusing only entries that match the
/// [`provisioning_ledger`] byte for byte; no ledger excuses nothing. Under
/// [`ProvisioningDirt::Discard`] (`--force`), when that check still finds
/// dirt, a linked worktree must pass [`force_blocker`] for `id`, and the dirt
/// check then also excuses tm's provisioning files as the in-project route
/// does: `TASK.md` only while it holds what tm wrote from `task`, the
/// record's task (#8688). Last, [`ignored_output_refusal`]. A `.git` entry
/// git cannot resolve keeps it. Any other directory must hold only harness
/// files and regenerable output ([`unversioned_content_refusal`]). Every
/// reason names the path.
/// Test: `owned_worktree_with_an_unpushed_commit_is_kept`,
/// `owned_worktree_with_untracked_results_is_kept`,
/// `owned_non_git_workspace_with_user_files_is_kept`,
/// `owned_workspace_is_kept_when_the_content_check_fails`,
/// `ledger_excuses_only_provisioning_dirt_on_a_clone`,
/// `locked_owned_worktree_is_kept_even_with_force`,
/// `owned_worktree_whose_probe_fails_is_kept`,
/// `force_on_owned_worktree_of_another_session_is_kept`.
pub(super) fn owned_workspace_keep_reason(
ws: &Path,
id: &ManagedSessionId,
task: Option<&str>,
policy: ProvisioningDirt,
) -> Option<String> {
if is_worktree_root(ws).unwrap_or(false) {
let named = |reason: String| Some(format!("{}: {reason}", ws.display()));
// #8663 critic round 2: only a proven main checkout skips the lock and
// `--force` gates; a probe that cannot answer keeps the tree.
let linked = match worktree_kind(ws) {
Ok(WorktreeKind::Linked(git_dir)) => Some(git_dir),
Ok(WorktreeKind::MainCheckout) => None,
Err(e) => return named(format!("{e}; nothing was removed")),
};
// #8663 critic round 1: a lock keeps it under both policies.
if let Some(blocker) = linked.as_deref().and_then(lock_blocker) {
return named(format!("{blocker}; nothing was removed"));
}
// #8663 critic round 1: tm's provisioning writes, proven by the ledger.
let ledger = provisioning_ledger::load(ws);
// #8688: one excuse for the count and the list; no ledger excuses nothing.
let ledgered = |line: &str| ledger.as_ref().is_some_and(|l| l.excuses(ws, line));
let Some(dirt) = inspect_dirt_excusing(ws, &ledgered) else {
return ignored_output_refusal(ws);
};
if policy == ProvisioningDirt::Refuse {
return named(kept_for_dirt(ws, &dirt.reason, policy, &ledgered));
}
if linked.is_some()
&& let Some(blocker) = force_blocker(ws, id)
{
return named(format!("--force declined: {blocker}; nothing was removed"));
}
// #8688: `task` decides whether `TASK.md` is tm's write.
let excuse = |line: &str| is_provisioning_entry(ws, task, line) || ledgered(line);
if let Some(dirt) = inspect_dirt_excusing(ws, &excuse) {
return named(kept_for_dirt(ws, &dirt.reason, policy, &excuse));
}
return ignored_output_refusal(ws);
}
match std::fs::symlink_metadata(ws.join(".git")) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => unversioned_content_refusal(ws),
// #8663: a `.git` git cannot resolve hides commits no check can count.
_ => Some(format!(
"{} has a `.git` entry git does not resolve to this directory, so its commits \
and changes cannot be checked; keeping it (#8663)",
ws.display()
)),
}
}
/// Delete an SM-owned workspace unless [`owned_workspace_keep_reason`] keeps
/// it (decommission effect 3, #8663).
///
/// Why: the check and the delete run in one blocking task, inside the
/// removal audit, so nothing written between them is lost unchecked and the
/// daemon executor never waits on `git status`.
/// What: `Ok` with the verdict — removed, or kept with the reason, logged
/// with the session id. A kept workspace leaves the record's pointer in place
/// (the tombstone step clears it only when nothing is on disk). A failed
/// `remove_dir_all` is `Err(ManagedError::Io)`, as before. A panicked task is
/// a keep. Under `--force` a `warn!` names the provisioning files deleted with
/// the tree.
/// #9444: a live process standing in the workspace, or a probe that cannot
/// answer ([`process_holding`]), keeps it, asked immediately before the delete.
/// Test: `owned_worktree_with_an_unpushed_commit_is_kept`,
/// `clean_owned_workspace_is_still_removed`,
/// `decommission_prunes_the_base_repo_worktree_registry`,
/// `owned_workspace_a_live_process_stands_in_is_kept_9444`.
pub(super) async fn remove_owned_workspace(
id: &ManagedSessionId,
task: Option<&str>,
ws: &Path,
policy: ProvisioningDirt,
) -> Result<WorkspaceVerdict, ManagedError> {
remove_owned_workspace_with(id, task, ws, policy, process_holding).await
}
/// [`remove_owned_workspace`] with the live-process probe passed in (#9444),
/// so a probe that cannot answer is shown to keep the workspace.
/// Test: `owned_workspace_is_kept_when_the_holder_probe_fails_9444`.
pub(super) async fn remove_owned_workspace_with(
id: &ManagedSessionId,
task: Option<&str>,
ws: &Path,
policy: ProvisioningDirt,
cwd_holder: fn(&Path) -> Option<String>,
) -> Result<WorkspaceVerdict, ManagedError> {
let path = ws.to_path_buf();
let owner = *id;
// #8688: `TASK.md` is excused only while it equals the record's task.
let task = task.map(str::to_owned);
let join = tokio::task::spawn_blocking(move || {
let mut failure: Option<std::io::Error> = None;
// #7885 critic round: audited like every other removal route.
let outcome = super::worktree_removal_audit::audited_removal(
&path,
"session decommission: owned workspace, containment guard passed",
|| {
let task = task.as_deref();
if let Some(reason) = owned_workspace_keep_reason(&path, &owner, task, policy) {
return WorktreeRemoval::Kept(reason);
}
// #9444: a live process standing in the workspace keeps it.
if let Some(holder) = cwd_holder(&path) {
return WorktreeRemoval::Kept(format!(
"refused immediately before removal: {holder}"
));
}
if policy == ProvisioningDirt::Discard {
warn_force_losses(&path);
}
match std::fs::remove_dir_all(&path) {
Ok(()) => WorktreeRemoval::Removed,
Err(e) => {
let kept =
WorktreeRemoval::Kept(format!("removing the workspace failed: {e}"));
failure = Some(e);
kept
}
}
},
);
(outcome, failure)
});
let (outcome, failure) = join.await.unwrap_or_else(|e| {
let reason = format!("the owned-workspace removal task panicked: {e}");
(WorktreeRemoval::Kept(reason), None)
});
if let Some(e) = failure {
return Err(ManagedError::Io(std::io::Error::new(
e.kind(),
format!("remove workspace {ws:?}: {e}"),
)));
}
match outcome.reason() {
Some(reason) => warn!(
id = %id, workspace = %ws.display(), reason = %reason,
"decommission: refusing to remove the owned workspace; leaving it on disk (the \
record is still tombstoned)"
),
None => info!(
id = %id, workspace = %ws.display(),
"decommission: owned workspace removed from disk"
),
}
Ok(WorkspaceVerdict {
removed: outcome.removed(),
kept_reason: outcome.reason().map(str::to_string),
})
}
/// Log the provisioning files `--force` is about to delete with `ws` (#8663).
fn warn_force_losses(ws: &Path) {
let lost = dirty_entries(ws);
if !lost.is_empty() {
// #8540: an edited ledgered file keeps the workspace, so none is here.
warn!(
workspace = %ws.display(),
"decommission --force: deleting tm's provisioning files with the workspace: {}",
lost.join(", ")
);
}
}
/// Why the shared remover's disowned-directory fallback must keep `path`, or
/// `None` to delete it (#8663).
///
/// Why: git holds no state there, so no dirt check ran; an agent that deleted
/// `.git` in its worktree lost its results to the orphan prune.
/// What: [`unversioned_content_refusal`], logged. Under
/// [`DirtyWorktreePolicy::ForceDiscard`] (`prune-worktrees --discard-dirty`)
/// found content is deleted after a `warn!` naming every kept entry and its
/// file count ([`discarded_entries`]); a failed check keeps the directory under
/// either policy.
/// Test: `unclaimed_directory_with_results_is_kept`,
/// `unclaimed_directory_holding_only_harness_files_is_removed`,
/// `unclaimed_directory_is_kept_when_the_content_check_fails`,
/// `force_discard_removes_an_unclaimed_directory_with_results`.
pub(super) fn unclaimed_directory_blocks_removal(
path: &Path,
policy: DirtyWorktreePolicy,
) -> Option<String> {
if policy == DirtyWorktreePolicy::ForceDiscard {
match kept_unversioned_content(path) {
Ok(None) => return None,
Ok(Some(out)) => {
warn!(
path = %path.display(),
"worktree removal: DISCARDING {} file(s) git holds no record of: {} — \
explicit force-discard opt-in was supplied (#8663)",
out.files,
discarded_entries(&out.entries)
);
return None;
}
// #8663: nothing can be named, so nothing is discarded.
Err(_) => {}
}
}
let refusal = unversioned_content_refusal(path)?;
warn!(path = %path.display(), "worktree removal refused — {refusal}");
Some(refusal)
}
/// How many entries [`discarded_entries`] names before it summarises.
const DISCARD_LOG_CAP: usize = 50;
/// `` `a` (3 files), `b` (1 file)`` for every kept top-level entry, capped at
/// [`DISCARD_LOG_CAP`] with `+N more` (#8663 critic round 1).
///
/// Why: `--discard-dirty` deletes all of them; a log naming only the first
/// leaves the operator unable to say what was lost.
/// Test: `discarded_entries_names_every_entry_up_to_the_cap`.
pub(super) fn discarded_entries(entries: &[(String, usize)]) -> String {
let mut named: Vec<String> = entries
.iter()
.take(DISCARD_LOG_CAP)
.map(|(entry, files)| {
let noun = if *files == 1 { "file" } else { "files" };
format!("`{entry}` ({files} {noun})")
})
.collect();
if entries.len() > DISCARD_LOG_CAP {
named.push(format!("+{} more", entries.len() - DISCARD_LOG_CAP));
}
named.join(", ")
}