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
//! 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 seven days
//! 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_on_disk` parameter for the guard that keeps a record-only
//! eviction from becoming a filesystem deletion by proxy, and
//! [`workspace_present`] for why an undetermined path counts as present.
//!
//! Test: `retention_tests.rs`.
use ;
use ;
use ;
use ;
/// How long a record stays in the store after entering a terminal state.
///
/// Why: the owner's ruling on the inflated-`NUM` report — "evict old records,
/// 7 days". Long enough that a tombstone an operator captured a number from is
/// still there days later (#3034's guarantee, which in practice never survived
/// a daemon restart anyway); short enough that the store stops being an
/// append-only log of every session ever run.
/// What: the retention window in days, applied to
/// [`SessionRecord::terminal_at`].
/// Test: `retention_window_is_seven_days`.
pub const TERMINAL_RECORD_RETENTION_DAYS: i64 = 7;
/// 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 a further seven days,
/// so the fix the owner asked for would not arrive until a week after he
/// installed it. 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 still exists 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_on_disk` keeps the record,
/// and therefore the protection, alive for exactly as long as there is a
/// directory to protect.
///
/// 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 is not on
/// disk; then `Stamp(inferred)` when undated, `Evict` when `now - terminal_at
/// >= retention`, `Keep` otherwise. `workspace_on_disk` is supplied by the
/// caller (via [`workspace_present`], which resolves an undetermined path to
/// `true`) so this function stays pure and does no I/O.
/// Test: `retention_verdict_keeps_live_states`,
/// `retention_verdict_keeps_record_whose_workspace_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`.
/// Is this record's workspace directory still on disk?
///
/// Why: `Path::exists()` maps EVERY stat error to `false` — a permission error
/// on an ancestor, a stale NFS/SMB handle, `EIO` from a departed volume — so
/// "cannot determine" and "not there" produce the same answer, and that answer
/// evicts. The guard this feeds is the one keeping a record's `workspace_path`
/// in `prune_orphaned_worktrees`'s protected set, so failing open here means a
/// transient stat error can hand a live worktree to an unattended sweep.
/// What: `probe` is `Path::try_exists` in production. An `Err` — undetermined —
/// counts as PRESENT, so an unreadable path is never evicted. A `None` path is
/// absent: there is nothing to protect and nothing to fail on.
/// Test: `workspace_present_treats_an_undetermined_path_as_present`.
/// Two-observation gate before a record may be evicted.
///
/// 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: "the workspace directory is gone" is a live
/// filesystem observation, and an external volume that unmounts between ticks
/// answers `Ok(false)` — genuinely absent, indistinguishable from deleted —
/// which [`workspace_present`]'s error handling cannot catch. Requiring the
/// same verdict on two ticks ~60s apart costs nothing against a 7-day window
/// and rules that case out.
/// What: [`Self::confirm`] returns the candidates that were ALSO candidates on
/// the previous call, then re-arms with the current set — so a record that
/// stops being a candidate (reactivated, workspace remounted) 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`.