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
//! Tests for `SessionManager::mark_reactivated` (#2023 component C).
//!
//! Why: `session_manager/tests.rs` is at the 1500-SLOC test cap; this
//! in-place-reactivation coverage lives here (mirroring `restart_tests.rs`)
//! so neither file grows past its limit.
//! What: proves `mark_reactivated` flips `Stopped`/`Decommissioned` (#2777) ->
//! `Active` WITHOUT ever calling `create_session`/`kill_session`, rejects a
//! non-reactivatable state, and (#2790) refuses the flip when the resolved
//! workspace no longer exists on disk (the real `decommission()` teardown
//! path, not just a harmless forced-state fixture).
//! Test: this file IS the test module; run with `cargo test -p trusty-mpm`.
use tempfile::TempDir;
use super::manager::ManagedError;
use super::record::{ManagedSessionId, ManagedSessionState};
use super::tests::make_manager;
/// `mark_reactivated` must flip a `Stopped` record back to `Active` WITHOUT
/// touching tmux at all (#2023 C) — the caller is about to `exec` `claude`
/// directly back into the SAME pane `mark_runtime_exited_stopped` (#2023 A)
/// left alive, so no `create_session`/`kill_session` call may occur.
///
/// Why: this is the daemon-side half of the bare-`tm` in-pane relaunch path;
/// unlike `resume` it must never recreate the tmux session.
/// What: creates a session, stops it (via `stop`, which DOES kill the pane —
/// irrelevant here, only the record's Stopped state matters), then calls
/// `mark_reactivated` and asserts the record is `Active` and that no
/// ADDITIONAL `create_session`/`kill_session` calls were made by
/// `mark_reactivated` itself.
/// Test: this function IS the test.
#[tokio::test]
async fn mark_reactivated_flips_stopped_to_active() {
let dir = TempDir::new().unwrap();
let workspace_dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(workspace_dir.path().to_owned()),
None,
Some(workspace_dir.path().to_owned()),
None,
None,
)
.await
.expect("create");
mgr.stop(&record.id).await.expect("stop");
let kill_calls_before = fake.kill_calls.lock().unwrap().len();
let create_calls_before = fake.create_cwd_calls.lock().unwrap().len();
let reactivated = mgr.mark_reactivated(&record.id).await.expect("reactivate");
assert_eq!(
reactivated.state,
ManagedSessionState::Active,
"mark_reactivated must flip Stopped -> Active"
);
assert_eq!(
fake.kill_calls.lock().unwrap().len(),
kill_calls_before,
"mark_reactivated must NEVER call kill_session (#2023 C)"
);
assert_eq!(
fake.create_cwd_calls.lock().unwrap().len(),
create_calls_before,
"mark_reactivated must NEVER call create_session (#2023 C)"
);
let after = mgr.get(&record.id).await.unwrap();
assert_eq!(after.state, ManagedSessionState::Active);
}
/// `mark_reactivated` must clear a lingering `claude_session_id` (#4337).
///
/// Why: a reactivation always precedes the client `exec`-ing a fresh `claude`
/// back into this SAME pane, so the id captured before the pane went idle is
/// guaranteed to belong to a conversation that already ended. Leaving it in
/// place would let the daemon's `SessionStart` correlation compare the
/// pane's brand-new top-level session against a dead id under the (narrower)
/// stale-transcript re-sync gate instead of taking the plain "no existing
/// id" path.
/// What: creates a session, stops it, sets `claude_session_id`, reactivates,
/// and asserts the reloaded record's id is `None`.
/// Test: this function IS the test.
#[tokio::test]
async fn mark_reactivated_clears_stale_claude_session_id() {
let dir = TempDir::new().unwrap();
let workspace_dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(workspace_dir.path().to_owned()),
None,
Some(workspace_dir.path().to_owned()),
None,
None,
)
.await
.expect("create");
mgr.set_claude_session_id(&record.id, "uuid-pre-reactivate")
.await
.expect("set_claude_session_id");
mgr.stop(&record.id).await.expect("stop");
mgr.mark_reactivated(&record.id).await.expect("reactivate");
let after = mgr.get(&record.id).await.unwrap();
assert_eq!(
after.claude_session_id, None,
"mark_reactivated must clear claude_session_id (#4337)"
);
}
/// `mark_reactivated` must also flip a `Decommissioned` record back to `Active`
/// IN PLACE, with no tmux mutation (#2777).
///
/// Why: after a session is decommissioned, its tmux pane can linger as a bare
/// shell printing "run `tm` to relaunch this session". When the operator does
/// exactly that from inside that pane, a switch-client reconnect to the session
/// they are already in is a no-op dead-end. Reviving the tombstone in place —
/// no `create_session`/`kill_session` — is the only non-destructive way to
/// honor that on-screen instruction (the pane survives to `exec` `claude`).
/// What: seeds a record, forces it `Decommissioned` via `set_workspace`, then
/// asserts `mark_reactivated` flips it to `Active` and calls neither
/// `create_session` nor `kill_session`.
/// Test: this function IS the test.
#[tokio::test]
async fn mark_reactivated_flips_decommissioned_to_active() {
let dir = TempDir::new().unwrap();
let workspace_dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(workspace_dir.path().to_owned()),
None,
Some(workspace_dir.path().to_owned()),
None,
None,
)
.await
.expect("create");
mgr.set_workspace(
&record.id,
workspace_dir.path().to_owned(),
ManagedSessionState::Decommissioned,
)
.await
.expect("force Decommissioned");
let kill_calls_before = fake.kill_calls.lock().unwrap().len();
let create_calls_before = fake.create_cwd_calls.lock().unwrap().len();
let reactivated = mgr
.mark_reactivated(&record.id)
.await
.expect("reactivate decommissioned");
assert_eq!(
reactivated.state,
ManagedSessionState::Active,
"mark_reactivated must flip Decommissioned -> Active (#2777)"
);
assert_eq!(
fake.kill_calls.lock().unwrap().len(),
kill_calls_before,
"mark_reactivated must NEVER call kill_session"
);
assert_eq!(
fake.create_cwd_calls.lock().unwrap().len(),
create_calls_before,
"mark_reactivated must NEVER call create_session"
);
}
/// `mark_reactivated` must REFUSE to flip a `Decommissioned` record to
/// `Active` when the workspace it would resume into no longer exists on disk
/// (#2790 code-critic CRITICAL/MEDIUM) — proven through the REAL
/// `decommission_with_root` teardown path, not the harmless
/// `set_workspace`-forced fixture (which leaves `workspace_path` pointing at
/// an existing tempdir).
///
/// Why: `decommission()` unconditionally clears `workspace_path` to `None` on
/// tombstone, so `workspace_path` alone cannot tell the daemon whether the
/// directory was actually removed. The guard in `mark_reactivated` falls back
/// to the record's ORIGINAL spawn `cwd` — the same field the in-place-relaunch
/// CLI path resumes into — and must refuse the state flip when THAT directory
/// is gone, so a corrupted `Active` ghost record (workspace gone, state says
/// running) can never be committed to the store, regardless of caller.
/// What: creates an OWNED session whose `cwd`/`workspace_path` live inside a
/// managed root, decommissions it for REAL (the workspace is actually removed
/// from disk), then asserts `mark_reactivated` returns `InvalidState` and that
/// the persisted record is STILL `Decommissioned` (no state flip occurred).
/// Test: this function IS the test.
#[tokio::test]
async fn mark_reactivated_refuses_when_workspace_removed_by_decommission() {
let dir = TempDir::new().unwrap();
// #8935: the record's pane proves the live session is its own.
let (mgr, _fake) = super::tests::make_manager_with_pane(&dir).await;
// Workspace INSIDE a temp "managed root" so decommission's path-containment
// guard (`is_safe_to_remove`) passes and the directory is actually deleted —
// mirrors `manager_decommission_removes_workspace`'s setup.
let managed_root = TempDir::new().unwrap();
let workspace_path = managed_root.path().join("owner").join("repo").join("sess");
std::fs::create_dir_all(&workspace_path).unwrap();
let record = mgr
.create_with_id(
ManagedSessionId::new(),
"task".into(),
Some(workspace_path.clone()),
None,
Some(workspace_path.clone()),
None,
None,
crate::runtime::RuntimeKind::default(),
false,
true, // owned: SM provisioned, so decommission actually removes it
)
.await
.expect("create");
let (tombstone, workspace_removed) = mgr
.decommission_with_root(&record.id, managed_root.path(), None)
.await
.expect("decommission");
assert!(
workspace_removed,
"precondition: the workspace must actually be removed from disk"
);
assert_eq!(tombstone.state, ManagedSessionState::Decommissioned);
assert!(
!workspace_path.exists(),
"precondition: workspace directory must be gone from disk"
);
let err = mgr
.mark_reactivated(&record.id)
.await
.expect_err("reactivating a session whose workspace is gone must fail");
assert!(
matches!(err, ManagedError::InvalidState(_, _)),
"expected InvalidState, got {err:?}"
);
let after = mgr
.get(&record.id)
.await
.expect("get after refused reactivate");
assert_eq!(
after.state,
ManagedSessionState::Decommissioned,
"the record must NOT be flipped to Active when its workspace is gone"
);
}
/// `mark_reactivated` must reject any state other than `Stopped`/`Decommissioned`
/// — in particular it must not silently re-mark an already-`Active` session.
///
/// Why: this guards against a stray/duplicate reactivate call (e.g. two
/// bare-`tm` invocations racing in the same pane) silently succeeding twice.
/// What: creates and stops a session so it is `Stopped`, reactivates it once
/// (succeeds), then reactivates it AGAIN and asserts an `InvalidState` error.
/// Test: this function IS the test.
#[tokio::test]
async fn mark_reactivated_rejects_non_stopped() {
let dir = TempDir::new().unwrap();
let workspace_dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(workspace_dir.path().to_owned()),
None,
Some(workspace_dir.path().to_owned()),
None,
None,
)
.await
.expect("create");
mgr.stop(&record.id).await.expect("stop");
mgr.mark_reactivated(&record.id)
.await
.expect("first reactivate");
let err = mgr
.mark_reactivated(&record.id)
.await
.expect_err("reactivating an already-Active session must fail");
assert!(
matches!(err, ManagedError::InvalidState(_, _)),
"expected InvalidState, got {err:?}"
);
}
/// Why: `mark_reactivated` is the sole production `Decommissioned -> Active`
/// transition, so it is the only place a record can leave a terminal state
/// still carrying the `terminal_at` stamp the retention sweep reads. It used to
/// assign `record.state` directly, which left the stamp intact — inert while
/// the verdict short-circuits on `!is_terminal()`, but a documented invariant
/// that is false is how that becomes a live bug. This drives the REAL path, not
/// the setter in isolation.
/// What: decommissions a record through `decommission` (which stamps), asserts
/// the stamp is set, reactivates, and asserts it is cleared.
/// Test: this test.
#[tokio::test]
async fn mark_reactivated_clears_the_terminal_stamp() {
let dir = TempDir::new().unwrap();
let workspace_dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager(&dir).await;
let record = mgr
.create(
"task".into(),
Some(workspace_dir.path().to_owned()),
None,
Some(workspace_dir.path().to_owned()),
None,
None,
)
.await
.expect("create");
mgr.set_workspace(
&record.id,
workspace_dir.path().to_owned(),
ManagedSessionState::Decommissioned,
)
.await
.expect("force Decommissioned");
// Stamp it the way a real decommission does.
let mut tombstoned = mgr.get(&record.id).await.expect("get");
tombstoned.set_lifecycle_state(ManagedSessionState::Decommissioned, chrono::Utc::now());
assert!(tombstoned.terminal_at.is_some(), "fixture is stamped");
mgr.store
.write()
.await
.upsert(tombstoned)
.await
.expect("persist stamp");
let reactivated = mgr.mark_reactivated(&record.id).await.expect("reactivate");
assert_eq!(reactivated.state, ManagedSessionState::Active);
assert_eq!(
reactivated.terminal_at, None,
"a revived record must not keep its death time"
);
assert_eq!(
mgr.get(&record.id).await.expect("get").terminal_at,
None,
"and the cleared stamp is what got persisted"
);
}