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
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
//! Age-based eviction of terminal session records, and the slot numbers they
//! hold.
//!
//! Why: `tm ls` showed `NUM 107` at the bottom of a 31-row listing. The slot
//! registry (#3034) hands every record it observes a number, and the default
//! view then hides the `decommissioned`/`deleted` ones — 76 of those 107 slots
//! were held by rows nobody could see. Nothing had ever left the store: the
//! auto-prune path decommissions `record_only`, which leaves the record in
//! place. Freeing numbers therefore requires evicting the records behind them,
//! not a smarter allocator — with no record ever removed, the free set is empty
//! and any "reuse the lowest free slot" scheme is a provable no-op.
//!
//! What: [`SessionManager::sweep_terminal_records`] hard-deletes records in a
//! terminal state ([`ManagedSessionState::is_terminal`]) once they have sat
//! there longer than [`TERMINAL_RECORD_RETENTION_DAYS`], and releases each
//! evicted record's slot so the number becomes available again.
//! [`retention_verdict`] is the pure decision function it drives.
//!
//! Records written before `terminal_at` existed — the whole pre-existing
//! backlog, which is what put `NUM` in three digits — are backfilled from
//! [`inferred_terminal_at`] rather than from the current time, so one already
//! weeks past the window is eligible on the next sweep instead of a full window
//! after this ships.
//!
//! Scope, and the reason it is drawn this tightly: this sweep touches
//! `sessions.json` and nothing else. It never removes a worktree, a workspace
//! directory, a git branch, or any other file — see [`retention_verdict`]'s
//! `workspace_needs_protection` parameter for the guard that keeps a
//! record-only eviction from becoming a filesystem deletion by proxy, and
//! `workspace_needs_protection` for which workspaces that guard now covers
//! and why an undetermined answer counts as protected.
//!
//! Test: `retention_tests.rs`.
//!
//! [`inferred_terminal_at`]: crate::session_manager::retention::inferred_terminal_at
use ;
use ;
use ;
use ;
/// How long a record stays in the store after entering a terminal state.
///
/// Why (#5327): protecting a worktree is `workspace_needs_protection`'s job,
/// and it holds a record for as long as there is one, at any age. All the clock
/// has to buy is the span in which an operator can still act on a record that
/// is already dead — a `NUM` read off a listing, or the #2777 in-pane revive,
/// which reads the record out of the store. Both are same-working-day actions
/// that must survive an overnight gap. 24 hours is also how long this crate
/// already waits before an automatic sweep acts on a session with no operator
/// present ([`super::MAX_EPHEMERAL_AGE_HOURS`]), and retention does strictly
/// less than that sweep: it deletes a record, never a process or a directory.
///
/// The unit stays days because the NAME is published API on a 1.x crate.
/// Renaming it to hours to write `24` instead of `1` is a major-version break
/// for a cosmetic gain.
/// What: the retention window in days, applied to
/// [`SessionRecord::terminal_at`].
/// Test: `retention_window_is_twenty_four_hours`.
pub const TERMINAL_RECORD_RETENTION_DAYS: i64 = 1;
/// What the sweep should do with one record.
///
/// Why: keeping the decision separate from the store and slot mutations makes
/// every branch — including the two that must never delete — testable without
/// a store, a daemon, or a clock.
/// What: `Keep` (leave it alone), `Stamp(when)` (terminal but never dated:
/// backfill `terminal_at` with the carried time), `Evict` (terminal, dated, and
/// past the window).
/// Test: `retention_verdict_*` in `retention_tests.rs`.
/// Best estimate of when a record without [`SessionRecord::terminal_at`]
/// entered its terminal state.
///
/// Why: every record written before `terminal_at` existed lacks it, and that is
/// the entire pre-existing backlog — 76 of 116 records on the reporting
/// machine, holding 76 of the slot numbers that put `NUM` in three digits.
/// Stamping those with `now` would grandfather them for another full retention
/// window, so the fix the owner asked for would not arrive until a day after he
/// installed it (and, pre-#5327, a week). Clearing that backlog is the point,
/// and a record whose last sign of life was two months ago has had its
/// retention window many times over.
///
/// The earlier reasoning against inference was right about the mechanism and
/// wrong about the goal: yes, the auto-prune (#4384/#4702) decommissions
/// long-idle records, so this can date a record earlier than it truly died —
/// but only for records that predate the field, and only in the direction of
/// clearing a backlog that is already weeks old.
///
/// What: the LATEST evidence the record carries that it was alive —
/// `last_activity_at` when set, else `created_at`, never earlier than
/// `created_at`. Taking the latest, not the earliest, keeps the inference as
/// conservative as the available data allows. A timestamp in the FUTURE is not
/// evidence of anything (clock skew, or a corrupt record); it falls back to
/// `now`, so such a record gets a full window rather than being deleted on the
/// strength of a value that cannot be true.
///
/// This is inherently one-time: every terminal transition from here on stamps a
/// real `terminal_at` via [`SessionRecord::set_lifecycle_state`], so no record
/// created after this ships reaches this function.
/// Test: `inferred_terminal_at_uses_the_latest_evidence_of_life`,
/// `inferred_terminal_at_falls_back_to_now_for_a_future_timestamp`.
/// Decide the fate of one record.
///
/// Why: three separate guards have to hold at once, and each has a way of
/// going quietly wrong.
///
/// 1. **Only terminal records are in scope.** An `active`/`stopped`/`errored`
/// record is live work — a `stopped` session is explicitly resumable, with
/// its workspace intact.
///
/// 2. **A record whose workspace is a worktree on disk is never evicted**,
/// however old. This is not tidiness, it is the load-bearing guard.
/// `prune_orphaned_worktrees` protects a worktree from deletion by finding
/// its path in the set of `workspace_path`s read from the store — a set that
/// is *deliberately unfiltered by state*, because a record carrying a
/// terminal state is routinely a live session with unsaved work (see the
/// measured case in `prune::reap_orphaned_worktrees`). Both of that sweep's
/// two independent reads of the protected set come from the store, so
/// deleting the record removes the path from BOTH at once — collapsing a
/// defense-in-depth pair into nothing and handing the worktree to an
/// unattended, `dry_run: false` timer. `workspace_needs_protection` keeps
/// the record, and therefore the protection, alive for exactly as long as
/// there is a worktree to protect — see that function for why a plain
/// main-checkout path (#5274) is not one and what it costs to be wrong.
///
/// 3. **`terminal_at == None` is backfilled, never evicted directly.** A record
/// predating the field is dated from [`inferred_terminal_at`] and written
/// back; only a LATER sweep can evict it. That ordering is deliberate: the
/// inferred date lands on disk before anything acts on it, so an operator
/// can see what date a record was assigned rather than discovering it by its
/// absence. It also means an old legacy record still passes through the
/// two-observation debounce and phase-3 re-validation like any other.
///
/// What: `Keep` unless the record is terminal AND its workspace needs no
/// protection; then `Stamp(inferred)` when undated, `Evict` when `now -
/// terminal_at >= retention`, `Keep` otherwise. `workspace_needs_protection` is
/// supplied by the caller (via the function of the same name, which resolves an
/// undetermined probe to `true`) so this function stays pure and does no I/O.
/// Test: `retention_verdict_keeps_live_states`,
/// `retention_verdict_keeps_record_whose_worktree_still_exists`,
/// `retention_verdict_stamps_undated_terminal_record`,
/// `retention_verdict_keeps_record_inside_window`,
/// `retention_verdict_evicts_record_outside_window`.
/// What one retention sweep did.
///
/// Why: the daemon logs it, and tests assert on it, without either having to
/// re-derive the decision.
/// What: how many undated terminal records were stamped, and the ids evicted.
/// Test: `sweep_*` in `retention_tests.rs`.
/// Would evicting this record expose a worktree to `prune_orphaned_worktrees`?
///
/// Why: a record's `workspace_path` is what puts a directory in that sweep's
/// protected set, so evicting the record is what can get the directory deleted.
/// Until #5327 the question asked here was the broader "is the workspace still
/// on disk", which was the right question when every session provisioned a
/// worktree. Under ADR-0037 as amended (#5274) the default session runs on the
/// project's own main checkout and records it as its `workspace_path` — a
/// directory that never goes away — so the broad form pinned every such record
/// in the store permanently, at any window. That is the measured cause of
/// #5327's unbounded `NUM`, and shortening the window alone would not have
/// moved it.
///
/// The narrowing is tied to what the sweep can actually reach. A candidate must
/// carry a `.trusty-mpm-worktree` ownership sentinel naming a resolvable owner
/// (#3649); an absent sentinel is reported and never removed. The sentinel has
/// exactly two write sites — `provisioner::workspace` and
/// `managed_routes::inproject` — and each writes it immediately after its own
/// `git worktree add`. A path with no sentinel was therefore not provisioned as
/// a session worktree, and the sweep will not delete it however the record
/// changes.
///
/// What: `probe` is `Path::try_exists` in production and answers both reads.
/// Protected when ANY of:
///
/// - the workspace path cannot be probed (`Err`) — `Path::exists()` collapses a
/// permission error, a stale NFS/SMB handle, and `EIO` from a departed volume
/// into "not there", and "not there" is what evicts;
/// - the path's immediate parent is a worktree base
/// ([`super::decommission::is_session_worktree`], a deliberate superset of
/// creation since #5204);
/// - a sentinel file exists at the path, or its existence cannot be determined.
///
/// The sentinel and shape clauses are independent on purpose. The sweep reads
/// the sentinel at deletion time, this reads it now, and a read that fails
/// transiently here would drop the protection for good while the sweep's own
/// later read succeeds; the shape clause covers that for the standard
/// `.worktrees` layout, and treating an undetermined sentinel probe as present
/// covers it for the rest. Unprotected only when there is no path, the path is
/// gone, or it is a plain directory with no sentinel — the main-checkout case.
/// #5897: `pub(super)` rather than module-private because
/// [`super::prune::PruneOutcome`]'s compaction branch releases the slot behind
/// the record it removes, and must gate that release on the SAME workspace
/// condition this sweep does. A second copy of the predicate in `prune.rs`
/// would drift, and the two paths disagreeing about who may free a slot is
/// what #5897 is. Prune adds one condition this sweep has no need of — that
/// the record is STILL a tombstone when the slot is freed, which it re-reads
/// from the store because its own snapshot predates an unbounded teardown
/// loop. This sweep already re-reads for the same reason, in
/// [`SessionManager::revalidate_for_eviction`].
/// Test: `workspace_needs_protection_treats_an_undetermined_path_as_protected`,
/// `workspace_needs_protection_covers_a_session_worktree`,
/// `workspace_needs_protection_ignores_a_plain_main_checkout`,
/// `prune_compaction_keeps_the_slot_when_the_worktree_is_still_on_disk`,
/// `a_migrated_tree_with_an_unresolvable_git_entry_stays_protected`.
pub
/// Two-observation gate before a candidate may be acted on destructively.
///
/// Why: the sibling destructive sweeps in the same orphan-GC tick
/// (`orphan_gc::OrphanGc`, `core::pid_registry::PidOrphanGc`) each require a
/// candidate to survive TWO consecutive observations before acting, and
/// retention deletes something no less permanent. The condition it reads is not
/// purely a persisted timestamp: "no worktree behind this workspace" is a live
/// filesystem observation, and an external volume that unmounts between ticks
/// answers `Ok(false)` — genuinely absent, indistinguishable from deleted —
/// which `workspace_needs_protection`'s error handling cannot catch.
/// Requiring the same verdict on two ticks ~60s apart costs one to two minutes
/// against a 24-hour window and rules that case out. #5327 kept it unchanged
/// and unrelaxed: shortening the window makes this sweep act sooner, which
/// raises what a single stale observation costs rather than lowering it.
///
/// Generic since #6536: the log-drain scheduler's `prune_after_upload` gates a
/// remote object delete on the same two-tick shape, keyed by a candidate that
/// is a manifest identity rather than a [`ManagedSessionId`] — genericising
/// over `T` let it reuse this exact gate instead of a second implementation of
/// the same contract (this project's common-entry-point rule).
/// What: [`Self::confirm`] returns the candidates that were ALSO candidates on
/// the previous call, then re-arms with the current set — so a candidate that
/// stops qualifying (reactivated, workspace remounted, re-uploaded) disarms
/// itself. Owned by the caller across ticks, exactly like its two siblings.
/// Test: `debounce_requires_two_consecutive_observations`,
/// `debounce_disarms_a_candidate_that_lapses`,
/// `sweep_spares_a_record_whose_worktree_appears_between_sweeps`.