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
//! A stop somebody asked for is not undone by an automatic resume (#6194).
//!
//! Why: `Stopped` says the runtime is not running and nothing else, so both
//! automatic relaunch paths — the supervisor sweep and boot reconciliation —
//! treated an operator's `tmux kill-session` and `tm session stop` as sessions
//! to revive. Two leaked sessions killed at the tmux level came back within
//! seconds on 2026-08-23, and only `tm session decommission` (a terminal,
//! workspace-deleting state) made them stay down. These tests pin both
//! directions of the distinction the fix introduces.
//! What: the [`SessionRecord::is_auto_resumable`] matrix and its serde
//! back-compat, the three transitions that write a [`StopCause`]
//! (`stop`, `mark_runtime_exited_stopped`, `resume`), and the boot-reconcile
//! half of the auto-resume gate. Two siblings hold the rest, each where its
//! harness already lives: `supervisor::tests` covers the sweep
//! (`tick_never_resumes_a_deliberately_stopped_session`,
//! `tick_still_resumes_a_session_whose_runtime_exited`), and
//! `daemon::state::tests` covers the tmux-gone reaper's two cases
//! (`reap_marks_a_targeted_kill_deliberate`,
//! `reap_leaves_a_whole_server_loss_auto_resumable`).
//! Test: this file IS the test module; run `cargo test -p trusty-mpm`.
use std::sync::Arc;
use chrono::Utc;
use tempfile::TempDir;
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord, StopCause};
use super::tests::FakeTmuxDriver;
use crate::session_manager::SessionManager;
use crate::test_support::hermetic_temp_dir;
/// Build a record in a given state/cause, rooted at a real directory.
///
/// Why: `resume` existence-checks the workdir before it will respawn anything,
/// so a test that wants to observe a respawn must hand it a directory that is
/// genuinely on disk. A single builder keeps the 24-field literal out of every
/// test.
/// Test: used by every test in this module.
fn record_at(
tmux_name: &str,
workspace: &TempDir,
state: ManagedSessionState,
stop_cause: Option<StopCause>,
) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.into(),
cwd: workspace.path().to_path_buf(),
task: "stop-cause fixture".into(),
state,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(workspace.path().to_path_buf()),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause,
kind: Default::default(),
}
}
/// Build a manager over a fake tmux with `record` already in its store.
///
/// `data_dir` is the store's own directory and must outlive the manager, so
/// the caller owns it.
async fn manager_with(
data_dir: &TempDir,
record: SessionRecord,
) -> (SessionManager, Arc<FakeTmuxDriver>) {
let fake = FakeTmuxDriver::new();
let mgr = SessionManager::new(data_dir.path(), fake.clone())
.await
.expect("manager");
mgr.store.write().await.upsert(record).await.expect("seed");
(mgr, fake)
}
// ── The predicate ────────────────────────────────────────────────────────────
/// Only a `Stopped` record whose stop nobody asked for may be auto-resumed.
///
/// Why: this predicate is the single question both automatic relaunch paths
/// ask, so the whole state × cause matrix is pinned here rather than inferred
/// from the two call sites. The `Deliberate` rows are the defect: before #6194
/// they read `true` and the session came back.
/// Test: this function IS the test.
#[test]
fn only_a_stop_nobody_asked_for_is_auto_resumable() {
let ws = hermetic_temp_dir();
let cases = [
(ManagedSessionState::Stopped, None, true),
(
ManagedSessionState::Stopped,
Some(StopCause::Unexpected),
true,
),
(
ManagedSessionState::Stopped,
Some(StopCause::Deliberate),
false,
),
(ManagedSessionState::Active, None, false),
(ManagedSessionState::Provisioning, None, false),
(ManagedSessionState::Errored, None, false),
(ManagedSessionState::Decommissioned, None, false),
(
ManagedSessionState::Deleted,
Some(StopCause::Unexpected),
false,
),
];
for (state, cause, expected) in cases {
let record = record_at("tm-matrix", &ws, state.clone(), cause);
assert_eq!(
record.is_auto_resumable(),
expected,
"state {state:?} with cause {cause:?} must read auto-resumable = {expected}"
);
}
}
/// A record persisted before this field existed keeps its old behavior.
///
/// Why: the store is a live file full of pre-#6194 rows. They must load — the
/// daemon reads the whole store on boot and a deserialize failure loses the
/// fleet — and they must load as auto-resumable, because that is what those
/// records did before the field existed. Silently flipping them to "left down"
/// would strand every stopped session on the first daemon boot after upgrade.
/// Test: this function IS the test.
#[test]
fn legacy_record_without_stop_cause_deserializes_as_auto_resumable() {
let ws = hermetic_temp_dir();
let mut record = record_at("tm-legacy", &ws, ManagedSessionState::Stopped, None);
record.stop_cause = Some(StopCause::Deliberate);
let mut json: serde_json::Value = serde_json::to_value(&record).expect("serialize");
assert!(
json.get("stop_cause").is_some(),
"the field must round-trip when it is set"
);
json.as_object_mut()
.expect("object")
.remove("stop_cause")
.expect("field present before removal");
let legacy: SessionRecord = serde_json::from_value(json).expect(
"a record written before stop_cause existed must still deserialize — the daemon reads \
the whole store on boot",
);
assert_eq!(legacy.stop_cause, None);
assert!(
legacy.is_auto_resumable(),
"a legacy stopped record must keep the auto-resume behavior it already had"
);
}
// ── The transitions that write a cause ───────────────────────────────────────
/// `stop` records that the stop was asked for.
///
/// Why: `stop` is the one path every "end this session" request reaches — `tm
/// session stop`, the HTTP and MCP stop routes, the idle auto-stop, and the
/// reaper that finds a record's tmux target gone while the daemon watched. One
/// write here covers all of them.
/// Test: this function IS the test.
#[tokio::test]
async fn stop_records_deliberate_cause() {
let ws = hermetic_temp_dir();
let record = record_at("tm-stopped", &ws, ManagedSessionState::Active, None);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, _fake) = manager_with(&data, record).await;
let stopped = mgr.stop(&id).await.expect("stop");
assert_eq!(stopped.state, ManagedSessionState::Stopped);
assert_eq!(stopped.stop_cause, Some(StopCause::Deliberate));
assert!(
!stopped.is_auto_resumable(),
"an explicit stop that an automatic resume can undo is the #6194 defect"
);
// Persisted, not just returned — the supervisor and the boot reconcile both
// read the store, never this return value.
assert_eq!(
mgr.get(&id).await.expect("reload").stop_cause,
Some(StopCause::Deliberate)
);
}
/// A runtime that exited on its own records an unrequested stop.
///
/// Why: this is the case auto-resume exists for, and the fix must not disable
/// it. The pane is still alive and nothing asked for the stop, so the session
/// stays a relaunch candidate.
/// Test: this function IS the test.
#[tokio::test]
async fn runtime_exit_records_unexpected_cause() {
let ws = hermetic_temp_dir();
let record = record_at("tm-exited", &ws, ManagedSessionState::Active, None);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, _fake) = manager_with(&data, record).await;
let stopped = mgr
.mark_runtime_exited_stopped(&id)
.await
.expect("runtime-exit reconcile");
assert_eq!(stopped.state, ManagedSessionState::Stopped);
assert_eq!(stopped.stop_cause, Some(StopCause::Unexpected));
assert!(stopped.is_auto_resumable());
}
/// Resuming a deliberately stopped session clears the cause.
///
/// Why: two things at once. An operator's own `tm session resume` must still
/// revive a session they stopped — the gate is on AUTOMATIC resume, not on
/// resume — and once it is running again the reason it last stopped no longer
/// describes it, so a later crash is auto-resumable as usual.
/// Test: this function IS the test.
#[tokio::test]
async fn resume_clears_the_stop_cause() {
let ws = hermetic_temp_dir();
let record = record_at(
"tm-resumed",
&ws,
ManagedSessionState::Stopped,
Some(StopCause::Deliberate),
);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, _fake) = manager_with(&data, record).await;
let resumed = mgr.resume(&id).await.expect(
"an explicit resume must still revive a deliberately stopped session — only the \
automatic paths are gated",
);
assert_eq!(resumed.state, ManagedSessionState::Active);
assert_eq!(resumed.stop_cause, None);
}
// ── The boot-reconcile half of the gate ──────────────────────────────────────
/// A deliberate stop survives a daemon restart.
///
/// Why: the supervisor gate alone would leave the defect reachable one boot
/// later. Boot reconciliation visits `Stopped` records too, and its gone arm
/// re-queued every one of them for auto-resume — so a session the operator
/// killed came back the next time the daemon started. `create_cwd_calls` is the
/// load-bearing assertion: against the pre-fix code the record reads `Active`
/// at the end anyway, because auto-resume respawned it.
/// Test: this function IS the test.
#[tokio::test]
async fn boot_reconcile_never_requeues_a_deliberately_stopped_session() {
let ws = hermetic_temp_dir();
let record = record_at(
"tm-killed",
&ws,
ManagedSessionState::Stopped,
Some(StopCause::Deliberate),
);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, fake) = manager_with(&data, record).await;
// No live tmux session by this name — the operator killed it.
mgr.reconcile_on_boot(true).await.expect("reconcile");
let created = fake.create_cwd_calls.lock().unwrap().clone();
assert!(
created.is_empty(),
"boot reconciliation respawned a session the operator had killed: {created:?}"
);
let after = mgr.get(&id).await.expect("reload");
assert_eq!(after.state, ManagedSessionState::Stopped);
assert_eq!(
after.stop_cause,
Some(StopCause::Deliberate),
"the gone arm must keep a cause an earlier stop already recorded, not overwrite it"
);
}
/// A session lost while the daemon was down is still restored, END TO END.
///
/// Why: the other direction. After a reboot every session's tmux target is
/// gone and nothing recorded a cause, which is precisely what `auto_resume`
/// was built to restore. The fix must not turn that into a fleet that stays
/// down.
///
/// #8233: boot reconcile no longer resumes inline — it runs INSIDE the
/// daemon's `session_manager()` initialisation, before a runtime relauncher can
/// be installed, so resuming there would mark the record `Active` behind a pane
/// with nothing in it. It marks the session `Stopped` and the supervisor's very
/// next sweep does the real resume. That hand-off is what has to work, so this
/// drives BOTH halves and asserts the session ends up relaunched through the
/// runtime adapter, not merely deferred.
/// Test: this function IS the test.
#[tokio::test]
async fn boot_reconcile_still_auto_resumes_a_session_lost_with_the_daemon() {
let ws = hermetic_temp_dir();
let record = record_at("tm-rebooted", &ws, ManagedSessionState::Active, None);
let id = record.id;
let data = hermetic_temp_dir();
let (mgr, fake) = manager_with(&data, record).await;
let mgr = Arc::new(mgr);
// Half one: boot reconcile sees the tmux target gone and stops the record
// without attributing a cause, which is what makes it auto-resumable.
let report = mgr.reconcile_on_boot(true).await.expect("reconcile");
assert_eq!(report.stopped, vec![id.to_string()]);
assert_eq!(
mgr.get(&id).await.expect("reload").state,
ManagedSessionState::Stopped,
"the record is handed to the supervisor as Stopped, never left Active"
);
// Half two: the supervisor sweep, with the runtime relauncher the daemon
// installs, actually brings it back.
let relauncher = Arc::new(CountingRelauncher::default());
assert!(mgr.install_relauncher(relauncher.clone()));
let cfg = crate::supervisor::SupervisorConfig {
auto_resume: true,
classify_idle: false,
..Default::default()
};
let tick = crate::supervisor::run_tick::<NeverClassifies>(&mgr, &cfg, None).await;
assert_eq!(
tick.resumed,
vec![id.to_string()],
"the supervisor must pick up what boot reconcile deferred to it"
);
assert_eq!(
relauncher.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"and the restore must go through the runtime adapter, not just recreate a pane"
);
let created = fake.create_cwd_calls.lock().unwrap().clone();
assert_eq!(
created.len(),
1,
"an unattributed disappearance is the post-reboot restore auto-resume exists for: \
{created:?}"
);
assert_eq!(created[0].1, ws.path().to_string_lossy());
let after = mgr.get(&id).await.expect("reload");
assert_eq!(after.state, ManagedSessionState::Active);
assert_eq!(
after.stop_cause, None,
"a resumed session carries no stop cause"
);
}
/// A relauncher that succeeds and counts, standing in for the daemon's.
#[derive(Default)]
struct CountingRelauncher {
calls: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl crate::session_manager::relaunch::RuntimeRelauncher for CountingRelauncher {
async fn relaunch(&self, _record: &SessionRecord) -> Result<(), String> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
/// `run_tick` is generic over a classifier even when handed `None`; this names
/// the type without ever being called (`classify_idle` is false here).
struct NeverClassifies;
impl crate::activity::monitor::LlmClassifier for NeverClassifies {
async fn classify(
&self,
_pane_text: &str,
) -> Result<(crate::activity::ActivityVerdict, u32, u32), crate::activity::ActivityError> {
unreachable!("classify_idle is false in every test that names this type")
}
}