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
//! In-place session reactivation, with NO tmux mutation (#2023 component C).
//!
//! Why: `manager.rs` was at the 500-SLOC production cap; adding this method
//! there would breach it. Following the same pattern as `hook_sync.rs` /
//! `adopt.rs` (sibling `impl SessionManager` blocks), this file isolates the
//! one write-path the bare-`tm` in-pane relaunch needs.
//! What: one inherent method, `SessionManager::mark_reactivated` — flips a
//! `Stopped` OR `Decommissioned` (#2777) record back to `Active` with no
//! `create_session`/`kill_session` call, unlike [`SessionManager::resume`]
//! (which always recreates the tmux session).
//! Test: `mark_reactivated_flips_stopped_to_active`,
//! `mark_reactivated_flips_decommissioned_to_active`,
//! `mark_reactivated_rejects_non_stopped` in `super::reactivate_tests`.
use chrono::Utc;
use tracing::info;
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
impl SessionManager {
/// Reactivate a Stopped session IN PLACE, with NO tmux mutation (#2023 C).
///
/// Why: [`Self::resume`] is the daemon-driven restart path — it always kills
/// any surviving tmux session and creates a fresh one, because it assumes
/// the caller has no live pane of its own to reuse. The bare-`tm` in-pane
/// relaunch (#2023 component C) is the opposite case: the operator is
/// running `tm` FROM INSIDE the very pane [`Self::mark_runtime_exited_stopped`]
/// (#2023 A) left alive when the runtime exited, and is about to `exec`
/// `claude` directly back into that SAME pane. Routing that reactivation
/// through `resume` would kill the pane out from under the process that
/// is about to relaunch into it. This method gives the in-pane path its own
/// non-destructive transition: flip the record back to `Active` and nothing
/// else — no `create_session`, no `kill_session`, no pane snapshot.
/// What: requires the record be `Stopped` OR `Decommissioned` (any other
/// state is an [`ManagedError::InvalidState`] — in particular, an `Active`
/// record must not be silently re-marked Active by a stray call); sets
/// `state = Active` and refreshes `last_activity_at`, then persists.
///
/// `Decommissioned` is accepted (issue #2777) for the bare-`tm`-inside-a-
/// decommissioned-session case: 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 — with no tmux mutation, so the
/// operator's current pane survives to `exec` `claude` into it — is the only
/// non-destructive way to honor that on-screen instruction. The revive is an
/// explicit, in-pane operator action, never automatic; if decommission had
/// removed the workspace, the resolved directory fails the on-disk
/// existence guard below and the state is never flipped — the caller falls
/// back to an actionable hint (`bin/tm`'s `guided_inplace`).
///
/// Defense-in-depth (#2790 code-critic CRITICAL/MEDIUM): `decommission()`
/// unconditionally clears `workspace_path` to `None` on tombstone — even
/// for the unowned/#1840 skip-deletion case where the directory was NEVER
/// actually removed — so `workspace_path` alone cannot distinguish
/// "physically gone" from "never tracked separately". The field that is
/// NEVER cleared, and the one the in-place relaunch actually resumes into
/// (`workspace_path.or(cwd)` in `bin/tm`'s
/// `guided_inplace::run_inplace_relaunch`), is the original spawn `cwd`.
/// This method independently refuses the state flip — the daemon-side
/// authority, regardless of what any CLI-side pre-check does or omits —
/// when that resolved directory no longer exists on disk, so a corrupted
/// `Active` ghost record (workspace gone, state says running) can never be
/// committed to the store.
///
/// Also clears `claude_session_id` back to `None` (#4337): a reactivation
/// always precedes the client `exec`-ing a fresh `claude` into this SAME
/// pane, so the next `SessionStart` is guaranteed to be that pane's own
/// new top-level process. Clearing the old id lets that correlation land
/// via the plain "no existing id" path in
/// `daemon::api::session_start_correlation` instead of relying on the
/// narrower stale-transcript re-sync gate.
/// Test: `mark_reactivated_flips_stopped_to_active`,
/// `mark_reactivated_flips_decommissioned_to_active`,
/// `mark_reactivated_rejects_non_stopped`,
/// `mark_reactivated_refuses_when_workspace_removed_by_decommission`,
/// `mark_reactivated_clears_stale_claude_session_id`; the #7087 residency
/// bump by `generation_increments_across_mark_reactivated` in
/// `daemon::managed_routes::residency`'s route tests.
pub async fn mark_reactivated(
&self,
id: &ManagedSessionId,
) -> Result<SessionRecord, ManagedError> {
let mut record = self.get(id).await?;
if !matches!(
record.state,
ManagedSessionState::Stopped | ManagedSessionState::Decommissioned
) {
return Err(ManagedError::InvalidState(
id.to_string(),
format!(
"cannot reactivate a session in state '{}'; only Stopped or Decommissioned \
sessions can be reactivated in place",
record.state
),
));
}
let resume_dir = record
.workspace_path
.as_deref()
.unwrap_or(record.cwd.as_path());
if !resume_dir.is_dir() {
return Err(ManagedError::InvalidState(
id.to_string(),
format!(
"cannot reactivate session '{id}': workspace '{}' no longer exists on disk",
resume_dir.display()
),
));
}
// Routed through `set_lifecycle_state` rather than assigning `state`
// directly: this is the one production `Decommissioned -> Active`
// transition, so it is also the one place a record can leave a terminal
// state still carrying the `terminal_at` stamp the retention sweep
// reads. The setter clears it.
record.set_lifecycle_state(ManagedSessionState::Active, Utc::now());
record.last_activity_at = Some(Utc::now());
// #6194: the operator is relaunching into this pane, so the reason it
// last stopped no longer describes it — same clear `resume` does.
record.stop_cause = None;
// #6568: and the same forgiveness. This path feeds `record_death`
// through `mark_runtime_exited_stopped`, so without this an operator
// reactivating repeatedly would build the flap streak with their own
// hands and get parked on the next ordinary exit.
self.note_operator_resume(id).await;
// #4337: a reactivation always precedes the client `exec`-ing a fresh
// `claude` back into this SAME pane, guaranteeing a genuinely NEW
// top-level `SessionStart` is imminent. Clearing the old id here
// means that `SessionStart`'s correlation lands via the plain
// "no existing id" branch instead of needing the (narrower) staleness
// re-sync gate — and prevents a lingering pre-reactivation id from
// ever being compared against a later, unrelated report.
record.claude_session_id = None;
self.store.write().await.upsert(record.clone()).await?;
// #7087: Stopped/Decommissioned -> Active re-enters the active-project
// set — a revived tombstone in particular is a project that was gone
// from the set entirely one call ago.
self.bump_residency_generation();
info!(
id = %id,
name = %record.tmux_name,
"managed session reactivated in place (#2023 C, no tmux mutation)"
);
Ok(record)
}
}