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
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! Automatic post-merge worktree reclaim, and the one place its probes are
//! assembled (#7504, epic #7505).
//!
//! Why: #2919 shipped the reclaim ENGINE and a manual verb — `tm session
//! prune-worktrees --merged-prs` — and nothing that fires it. The PM therefore
//! removed each merged worktree by hand, and on 2026-09-11 disk hit 90% because
//! that step is a chore nobody owns. The owner's ruling for epic #7505 is that
//! disk management is a deterministic DAEMON function: the daemon decides, by
//! rule evaluation over observable state, and writes an audit line carrying its
//! reason. This module is that function for the post-merge half.
//!
//! What: [`reclaim`] is the single production entry point — the one place the
//! five probes the engine needs are bound to real sources. Both callers go
//! through it, so an operator-typed `--merged-prs` and the automatic sweep can
//! never apply different gates: the `prune-worktrees` route (which still decides
//! Report-vs-Remove from its own `dry_run`) and [`reclaim_loop`], the daemon's
//! background sweep. [`SweepReport`] is the summary the loop logs.
//!
//! # The trigger is PR-merged state, not the clock
//!
//! The cadence decides only when the daemon ASKS. What it acts on is
//! `gh`-reported merge state, re-read per candidate immediately before that
//! candidate's deletion by
//! [`recheck_before_delete`](crate::session_manager::worktree_reclaim_sweep::recheck_before_delete).
//! A worktree whose pull request is open, closed-unmerged, or unresolvable is
//! never removed however often the sweep runs — so the interval cannot make the
//! sweep guess, only make it late.
//!
//! # Why this one runs UNBOUNDED, unlike the doctor probe
//!
//! [`SurveyBudget`](crate::session_manager::worktree_reclaim_sweep::SurveyBudget)
//! bounds the read-only doctor survey at 3 seconds because an interactive
//! diagnostic must answer inside the client's timeout. A destructive pass cannot
//! take that trade: a partial classification does not shrink a REPORT, it shrinks
//! what gets reclaimed, which is the exact failure this issue exists to fix. So
//! the sweep inherits the engine's unbounded budget and pays for it with a slow
//! default cadence and [`MissedTickBehavior::Delay`], which together stop a sweep
//! that overran its interval from re-firing the instant it finishes.
//!
//! # Fail direction
//!
//! Toward keeping the directory, at every layer. A sweep that cannot run reclaims
//! nothing and says so at `warn`; a panicked blocking task is an `Err` the caller
//! reports rather than an empty success; a removal that did not complete lands in
//! `removal_failed` and makes [`SweepReport::failed`] true, so the loop logs the
//! tick at `warn` instead of `info`. No arm turns a failure into an advanced
//! state.
//!
//! Test: `merged_pr_reclaim_tests`.
use ;
use Arc;
use Duration;
use MissedTickBehavior;
use ;
use crateDaemonState;
use crate;
use crateprocess_launch_dirs;
use cratereclaim_merged_pr_worktrees;
use crateWorktreeScope;
/// Environment variable that disables the automatic sweep entirely.
///
/// Why: the sweep deletes directories, so an operator must be able to turn it off
/// without editing a config file or stopping the daemon's other loops. It
/// defaults ON — the owner's ruling is that this is a daemon function rather than
/// a PM chore, and a reclaim that has to be enabled is the manual step #2919
/// already shipped.
/// What: `0`/`false`/`off`/`no` (trimmed, case-insensitive) disables; anything
/// else, including unset, enables. Mirrors `TRUSTY_MPM_ORPHAN_GC`.
/// Test: `parse_enabled_defaults_on_and_honours_the_off_switch`.
pub const ENV_ENABLED: &str = "TRUSTY_MPM_WORKTREE_RECLAIM";
/// Environment variable overriding the sweep cadence, in seconds.
///
/// Test: `parse_interval_falls_back_on_junk_and_zero`.
pub const ENV_INTERVAL_SECS: &str = "TRUSTY_MPM_WORKTREE_RECLAIM_INTERVAL_SECS";
/// Default sweep cadence: one hour.
///
/// Why an hour rather than the orphan-GC loop's minute: one pass costs a `gh`
/// lookup per registered worktree plus an unbounded byte walk, which on this
/// repository has exceeded 600 seconds over 46 worktrees. A merged worktree costs
/// only disk until it goes, so being up to an hour late is free; re-walking a
/// terabyte every minute is not.
/// Test: `parse_interval_falls_back_on_junk_and_zero`.
pub const DEFAULT_INTERVAL_SECS: u64 = 3600;
/// Pure parse of [`ENV_ENABLED`] into an on/off decision.
///
/// Why pure: env vars are process-global, and ~20 tests in this binary write
/// them. Taking the raw value keeps the policy testable with no mutation at all
/// (#5544).
/// Test: `parse_enabled_defaults_on_and_honours_the_off_switch`.
pub
/// Whether the automatic sweep should be spawned at all.
///
/// Test: the policy is covered by `parse_enabled_defaults_on_and_honours_the_off_switch`.
pub
/// Pure parse of [`ENV_INTERVAL_SECS`] into seconds.
///
/// Why it rejects zero: `tokio::time::interval` panics on a zero period, and a
/// near-zero one would busy-loop a pass that spawns subprocesses. An unparsable
/// or non-positive value falls back to [`DEFAULT_INTERVAL_SECS`] rather than
/// disabling the sweep — disabling is [`ENV_ENABLED`]'s job, and inferring it
/// from a typo would turn a fat-fingered number into silent non-reclamation.
/// Test: `parse_interval_falls_back_on_junk_and_zero`.
pub
/// One tick's outcome, reduced to the four numbers the audit line carries.
///
/// Why a type rather than four locals: [`Self::failed`] is the thing the loop
/// branches on, and the Fail-Open rule means that branch must exist in exactly
/// one place. Reading `removal_failed.is_empty()` at the log site is how a later
/// edit drops it.
/// What: counts, plus the bytes the engine actually attributed to removals.
/// Test: `a_tick_with_a_failed_removal_is_reported_as_a_failure`,
/// `a_clean_tick_is_not_a_failure`.
pub
/// The sweep cadence, honouring [`ENV_INTERVAL_SECS`].
///
/// Test: the policy is covered by `parse_interval_falls_back_on_junk_and_zero`.
pub
/// The workspace root the operator's config names — what production callers pass
/// as [`reclaim`]'s `repos_root`.
///
/// Why a named function: the `prune-worktrees` route already resolves this for its
/// orphan pass, and the automatic sweep must resolve the SAME root or the two
/// reclaim paths would walk different workspaces.
/// Test: covered through the callers; the resolution itself is
/// `core::trusty_tools_config`'s.
pub
/// Run the merged-PR reclaim with every production probe bound (#7504).
///
/// Why this exists as a function: binding the probes is the part that is easy to
/// get subtly wrong — `in_use_now` must RE-READ the store per candidate rather
/// than close over a snapshot, the keep-list must be read fallibly so an
/// unparsable config keeps everything, and the agent probe must reach the
/// delegation registry rather than the session records. That assembly lived
/// inline in the `prune-worktrees` route; a second copy for the automatic sweep
/// is exactly the divergence the common-entry-point rule forbids, so there is one
/// copy and both callers use it.
/// What: resolves the adopted anchors and this process's own launch directories,
/// then runs [`reclaim_merged_pr_worktrees`] on the blocking pool — the engine is
/// synchronous and spends minutes in `git`, `gh` and filesystem walks. `mode`
/// decides whether anything is deleted; `invoking_session` is the caller's own
/// managed-session id, which gate 2 uses to tell the caller's claim from a
/// stranger's (#6806) and which the daemon's own sweep leaves `None` because it
/// occupies no pane.
///
/// `repos_root` is a PARAMETER rather than resolved here, for the reason #7357
/// made `adopted` one: resolving it internally makes this function's own tests
/// survey the operator's real workspace with an unbounded budget, which on this
/// repository exceeds 600 seconds. [`configured_workspace_root`] is what
/// production callers pass.
///
/// A panicked pass is `Err`, never an empty `ReclaimOutcome`: "reclaimed nothing"
/// and "crashed" must not render identically.
/// Test: `reclaim_on_an_empty_root_reclaims_nothing`.
pub async
/// Spawn the automatic sweep unless an operator switched it off (#7504).
///
/// Why the gate lives here rather than at the call site: the daemon's boot
/// sequence should read as one line per background loop, and the env contract for
/// this loop belongs beside the loop it governs — the same split
/// `spawn_idle_reaper_if_enabled` already uses.
/// What: reads [`ENV_ENABLED`] and [`ENV_INTERVAL_SECS`], then spawns
/// [`reclaim_loop`]; when disabled it logs the variable that disabled it, so an
/// operator wondering why nothing is being reclaimed finds the answer in the
/// daemon log rather than by reading this source.
/// Test: the policy is `parse_enabled_defaults_on_and_honours_the_off_switch`;
/// the loop it spawns is `reclaim_loop_exits_on_cancel`.
pub
/// The daemon's automatic post-merge reclaim sweep (#7504).
///
/// Why a loop rather than a webhook: the authority on "has this merged" is
/// GitHub, and this harness has no inbound path from it. Asking `gh` on a cadence
/// is the event source available, and the gates make a late answer harmless — see
/// the module docs on why the clock is not the trigger.
/// What: one pass immediately (so a restart cleans up promptly), then every
/// [`interval`] until `cancel` fires. Each tick refuses outright on a host state
/// that must not be swept — the #6348 quadrant, where a scratch-rooted daemon
/// would otherwise reclaim the operator's real worktrees — then runs [`reclaim`]
/// in [`ReclaimMode::Remove`] and logs the tick. A tick with any failed removal
/// logs at `warn`; a tick that reclaimed nothing and failed nothing logs nothing,
/// because an hourly "reclaimed 0" is noise that trains operators to filter the
/// line that matters.
/// Test: `reclaim_loop_exits_on_cancel`,
/// `reclaim_loop_reclaims_nothing_on_a_scratch_framework_root`.
pub async
/// One sweep, gated on host state and logged (#7504).
///
/// Split from [`reclaim_loop`] so a test can drive exactly one pass without
/// racing a timer.
/// Test: `reclaim_loop_reclaims_nothing_on_a_scratch_framework_root`.
pub async