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
//! In-memory, daemon-lifetime session slot numbering (issue #3034).
//!
//! Why: `tm ls` used to renumber sessions on every fetch — a plain `i + 1`
//! over whatever array the daemon happened to return. Deleting a session
//! shifted every subsequent row's displayed number, so an operator (or PM
//! agent) holding a number captured from an earlier listing could act on the
//! WRONG session. Bob's fix requirement: a session's displayed number is
//! assigned once and never reused or reshuffled for the life of the daemon
//! process; a deleted session's slot stays reserved and renders as a
//! tombstone instead of being handed to the next session; numbering resets
//! only when the daemon restarts.
//! What: [`SlotRegistry`] assigns a `u32` to each [`ManagedSessionId`] the
//! first time it is observed via [`SlotRegistry::observe`] and never reassigns
//! it while the session's record is around. A number becomes reusable only
//! after [`SlotRegistry::release`], which the record-retention sweep
//! ([`super::retention`]) calls once a terminal record has aged out of the
//! store entirely, and which [`super::prune`] calls when it compacts a
//! tombstone out of the store (#5897) — either way there is no record left to
//! point at, so the tombstone guarantee above still holds for as long as one
//! exists. Both callers first check `workspace_needs_protection`. It is
//! deliberately NOT persisted to disk — the
//! registry lives only inside the in-memory [`super::manager::SessionManager`]
//! that owns it, so a fresh daemon process starts with a fresh, empty
//! registry. That alone satisfies "reset numbering on restart"; no explicit
//! reset logic is needed. [`NumberedSlot`] is the per-slot view
//! [`super::manager::SessionManager::numbered_snapshot`] returns: `Some`
//! record for a live session, `None` for a slot whose session has since
//! disappeared from the store (hard-deleted, decommission-reaped, or pruned —
//! any removal path, not just the explicit delete verb).
//! Test: `slot_registry_*` below cover assignment stability, no-reuse, and
//! the source_id snapshot used to filter tombstones by project.
use std::collections::{BTreeMap, HashMap};
use super::record::{ManagedSessionId, SessionRecord};
/// Last-known metadata for one assigned slot, retained even after the
/// session's record is gone so a tombstone row can still be attributed to its
/// project for `--source-id` filtering.
#[derive(Debug, Clone)]
struct SlotMeta {
id: ManagedSessionId,
source_id: Option<String>,
}
/// Maps session ids to stable, daemon-lifetime slot numbers.
///
/// Why: two concurrent `tm ls` invocations (or two redraws of the same
/// interactive picker) must agree on every session's number — a purely
/// client-side per-fetch enumeration cannot give that guarantee. Owning the
/// assignment here, in the one daemon-side registry every listing call reads
/// and writes, is what makes the numbering authoritative.
/// What: `id_to_slot` is the reverse index; `slots` holds metadata for every
/// CURRENTLY-HELD slot, in slot order. Slot 0 is never assigned, so it doubles
/// as an unambiguous "no slot" sentinel for callers that default-construct a
/// summary.
/// Test: `slot_registry_assigns_stable_increasing_numbers`,
/// `slot_registry_never_reuses_a_number`,
/// `slot_registry_refreshes_source_id_while_live`,
/// `slot_registry_release_frees_the_slot_for_reuse`.
#[derive(Debug)]
pub struct SlotRegistry {
id_to_slot: HashMap<ManagedSessionId, u32>,
slots: BTreeMap<u32, SlotMeta>,
}
impl SlotRegistry {
/// Construct an empty registry — the daemon-restart reset point (#3034).
pub fn new() -> Self {
Self {
id_to_slot: HashMap::new(),
slots: BTreeMap::new(),
}
}
/// The lowest slot number no session currently holds.
///
/// Why: before record retention existed, the held set was gap-free — the
/// registry only ever inserted — so "lowest free" and "one past the
/// highest" were the same number and this scan would have been a provable
/// no-op. [`Self::release`] is what creates gaps, and reusing them is what
/// keeps `NUM` from climbing forever in a long-lived daemon.
/// What: walks the slot-ordered keys, returning the first integer ≥ 1 that
/// is absent. O(held slots) per assignment, against a set bounded by the
/// live fleet (hundreds at most).
/// Test: `slot_registry_release_frees_the_slot_for_reuse`.
fn lowest_free(&self) -> u32 {
let mut candidate = 1;
for &slot in self.slots.keys() {
if slot > candidate {
break;
}
candidate = slot + 1;
}
candidate
}
/// Assign (or return the existing) stable slot number for `id`.
///
/// Why: the single assignment point. Refreshing `source_id` on every
/// observation (rather than only at first assignment) keeps a legacy
/// record's initially-`None` source_id current once it is set, without ever
/// changing the assigned slot number.
/// What: returns the previously-assigned slot when `id` is already known;
/// otherwise takes [`Self::lowest_free`] and records `id`/`source_id`
/// there. The returned slot is never one another session already holds, so
/// no two live sessions can ever display the same `NUM`.
/// Test: `slot_registry_assigns_stable_increasing_numbers`,
/// `slot_registry_refreshes_source_id_while_live`,
/// `slot_registry_release_frees_the_slot_for_reuse`.
pub fn observe(&mut self, id: ManagedSessionId, source_id: Option<&str>) -> u32 {
if let Some(&slot) = self.id_to_slot.get(&id) {
if let Some(meta) = self.slots.get_mut(&slot) {
meta.source_id = source_id.map(str::to_owned);
}
return slot;
}
let slot = self.lowest_free();
self.id_to_slot.insert(id, slot);
self.slots.insert(
slot,
SlotMeta {
id,
source_id: source_id.map(str::to_owned),
},
);
slot
}
/// Drop `id`'s slot, freeing the number for a later session.
///
/// Why (retention): a slot is reserved so an operator who captured its
/// number sees a `-- deleted --` tombstone rather than a neighbour's
/// session (#3034). Once the record behind it has aged out of the store
/// entirely there is nothing left to point at, and holding the number
/// forever is what let `NUM` reach 107 in a 31-row listing. This is the
/// ONLY method that removes a slot. Two paths call it — the retention sweep
/// and, since #5897, `prune_managed`'s compaction branch — and both first
/// check `workspace_needs_protection`, so a slot is never freed while its
/// session worktree is still on disk. An ordinary deletion (which leaves the
/// record in the store) still tombstones exactly as before.
/// What: removes `id` from both indexes and returns the freed slot, or
/// `None` when `id` held none.
/// Test: `slot_registry_release_frees_the_slot_for_reuse`,
/// `slot_registry_release_of_unknown_id_is_a_noop`.
pub fn release(&mut self, id: &ManagedSessionId) -> Option<u32> {
let slot = self.id_to_slot.remove(id)?;
self.slots.remove(&slot);
Some(slot)
}
/// Every currently-held slot number, ascending.
///
/// Why: released slots leave gaps, so a `1..=max` walk would emit rows for
/// numbers nobody holds. Callers iterate this instead.
/// What: the slot-ordered keys of the held set.
/// Test: `slot_registry_release_frees_the_slot_for_reuse`.
pub fn assigned_slots(&self) -> Vec<u32> {
self.slots.keys().copied().collect()
}
/// The highest slot number currently held, or 0 when none is.
pub fn max_slot(&self) -> u32 {
self.slots.keys().next_back().copied().unwrap_or(0)
}
/// The session id last observed at `slot`, if any was ever assigned there.
pub fn id_at(&self, slot: u32) -> Option<ManagedSessionId> {
self.slots.get(&slot).map(|m| m.id)
}
/// The last-known `source_id` recorded for `slot`, if any.
pub fn source_id_at(&self, slot: u32) -> Option<&str> {
self.slots.get(&slot).and_then(|m| m.source_id.as_deref())
}
}
impl Default for SlotRegistry {
fn default() -> Self {
Self::new()
}
}
/// One row of the daemon's stable-numbered session view (#3034).
///
/// Why: [`super::manager::SessionManager::numbered_snapshot`] returns one of
/// these per slot 1..=max so the HTTP layer can render either the live
/// summary or a `-- deleted --` tombstone without needing its own copy of the
/// registry's bookkeeping.
/// What: `record` is `Some` while the session is still present in the store,
/// `None` once it disappears (any removal path); `source_id` is the
/// registry's last-known value, used to filter tombstone rows by project even
/// though the record itself is gone.
/// Test: covered by `numbered_snapshot_*` in `session_manager::tests`.
#[derive(Debug, Clone)]
pub struct NumberedSlot {
/// Stable, daemon-lifetime slot number (1-based).
pub slot: u32,
/// The live record, or `None` for a tombstoned slot.
pub record: Option<SessionRecord>,
/// Last-known `source_id`, retained for filtering even after deletion.
pub source_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn id() -> ManagedSessionId {
ManagedSessionId::from(Uuid::new_v4())
}
#[test]
fn slot_registry_assigns_stable_increasing_numbers() {
let mut reg = SlotRegistry::new();
let a = id();
let b = id();
let slot_a = reg.observe(a, Some("owner/repo"));
let slot_b = reg.observe(b, None);
assert_eq!(slot_a, 1);
assert_eq!(slot_b, 2);
// Re-observing returns the SAME number, never a fresh one.
assert_eq!(reg.observe(a, Some("owner/repo")), 1);
assert_eq!(reg.max_slot(), 2);
}
#[test]
fn slot_registry_never_reuses_a_number() {
let mut reg = SlotRegistry::new();
let a = id();
let b = id();
let c = id();
assert_eq!(reg.observe(a, None), 1);
assert_eq!(reg.observe(b, None), 2);
// `a`'s slot is never handed to a session observed after it, even
// though nothing here ever "deletes" `a` from the registry (deletion
// is a store-membership concept the registry itself is agnostic to —
// see `numbered_snapshot`).
assert_eq!(reg.observe(c, None), 3);
assert_eq!(reg.id_at(1), Some(a));
}
#[test]
fn slot_registry_refreshes_source_id_while_live() {
let mut reg = SlotRegistry::new();
let a = id();
reg.observe(a, None);
assert_eq!(reg.source_id_at(1), None);
reg.observe(a, Some("owner/repo"));
assert_eq!(reg.source_id_at(1), Some("owner/repo"));
}
/// Why: retention's whole effect on `NUM` depends on a released slot
/// becoming available again — without reuse, evicting slot 1 leaves a
/// permanent hole and new sessions keep climbing. This also pins the
/// no-duplicate invariant across the release/reallocate cycle.
/// What: releases the middle of three slots, asserts it disappears from the
/// held set, is handed to the NEXT session observed, and that no two live
/// ids ever map to the same number.
/// Test: this test.
#[test]
fn slot_registry_release_frees_the_slot_for_reuse() {
let mut reg = SlotRegistry::new();
let (a, b, c) = (id(), id(), id());
assert_eq!(reg.observe(a, None), 1);
assert_eq!(reg.observe(b, None), 2);
assert_eq!(reg.observe(c, None), 3);
assert_eq!(reg.release(&b), Some(2));
assert_eq!(reg.assigned_slots(), vec![1, 3]);
assert_eq!(reg.id_at(2), None, "released slot points at nothing");
assert_eq!(reg.max_slot(), 3);
// The freed number is handed to the next NEW session, not 4.
let d = id();
assert_eq!(reg.observe(d, None), 2);
// …and the sessions still holding slots kept theirs.
assert_eq!(reg.observe(a, None), 1);
assert_eq!(reg.observe(c, None), 3);
let mut held = vec![
reg.observe(a, None),
reg.observe(c, None),
reg.observe(d, None),
];
held.sort_unstable();
held.dedup();
assert_eq!(held.len(), 3, "no two live sessions share a NUM");
}
#[test]
fn slot_registry_release_of_unknown_id_is_a_noop() {
let mut reg = SlotRegistry::new();
let a = id();
reg.observe(a, None);
assert_eq!(reg.release(&id()), None);
assert_eq!(reg.assigned_slots(), vec![1], "held set untouched");
}
#[test]
fn slot_registry_unassigned_slot_and_max_are_empty() {
let reg = SlotRegistry::new();
assert_eq!(reg.max_slot(), 0);
assert_eq!(reg.id_at(1), None);
assert_eq!(reg.source_id_at(1), None);
}
}