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
//! Session creation: derive/accept a tmux name, create the host, persist the
//! record (issue #2032; extracted from `manager.rs` to keep it under its
//! 500-SLOC production cap — same pattern as `reactivate.rs`/`hook_sync.rs`).
//!
//! Why: issue #2032 needs a THIRD create path — alongside the existing
//! derive-the-name [`SessionManager::create`]/[`SessionManager::create_with_id`]
//! — for the in-project spawn routing layer, which must resolve the semantic
//! tmux name BEFORE the per-session git worktree exists (the worktree is now
//! named after the tmux name, not the raw session UUID) and therefore cannot
//! let `create_with_id` re-derive a (possibly different) name afterwards.
//! [`SessionManager::create_with_reserved_name`] accepts that pre-reserved
//! name directly. All three converge on one private tail,
//! [`SessionManager::create_with_resolved_name`], so the
//! tmux-guard/correlation/persist dance is written exactly once.
//! What: `create_with_id` (derives via [`super::naming`]'s
//! `resolve_session_name`, then delegates), `create_with_reserved_name`
//! (skips derivation, trusts the caller), `create_with_resolved_name` (shared
//! tail: create tmux session, arm the orphan-reaping guard, build and persist
//! the [`SessionRecord`]).
//! Test: `manager_create_record`, `manager_create_persists_runtime`,
//! `manager_create_persists_ephemeral_flag` in `tests.rs`; the in-project
//! spawn integration tests exercise `create_with_reserved_name` transitively.
use std::path::PathBuf;
use chrono::Utc;
use tracing::{info, warn};
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
impl SessionManager {
/// Create a new managed session with a caller-supplied session id.
///
/// Why: the `spawn_session` handler must provision the workspace BEFORE
/// creating the tmux session so that the tmux pane is rooted in the
/// provisioned directory (not `$HOME`). Provisioning requires the session id
/// upfront (it is embedded in the workspace path). This method lets the
/// handler pre-generate the id, provision, and then call here with `cwd =
/// Some(workspace_path)` so `tmux new-session -c <workspace>` is issued.
/// What: identical to [`SessionManager::create`] except the id and runtime
/// backend are supplied by the caller. Derives the tmux name (issue #1955
/// priority: `name_hint` > GitHub-parseable `repo_url` > `cwd` basename)
/// via [`SessionManager::resolve_session_name`] (`naming.rs`, shared
/// verbatim with the in-project spawn path's pre-worktree name
/// reservation — see [`Self::create_with_reserved_name`]), then delegates
/// to [`Self::create_with_resolved_name`] for the actual tmux-create +
/// persist steps. The `ephemeral` flag (#1508) tags test/throwaway
/// sessions so the bulk-teardown and age-based reap paths may
/// decommission them automatically; real callers pass `false`. The
/// `owned` flag (#1511) must be `true` ONLY when the SM provisioned the
/// workspace via git clone; local-path spawn, adopt, and reconcile all
/// pass `false` (safe default — never delete).
/// Test: `spawn_session_tmux_cwd_is_workspace` in session_manager/tests.rs;
/// `handler_spawn_creates_tmux_at_workspace_cwd` in session_manager_mvp.rs;
/// `manager_create_persists_runtime` in session_manager/tests.rs;
/// `manager_create_persists_ephemeral_flag` in session_manager/tests.rs.
#[allow(clippy::too_many_arguments)]
pub async fn create_with_id(
&self,
id: ManagedSessionId,
task: String,
cwd: Option<PathBuf>,
name_hint: Option<String>,
workspace_path: Option<PathBuf>,
repo_url: Option<String>,
branch: Option<String>,
runtime: crate::runtime::RuntimeKind,
ephemeral: bool,
owned: bool,
) -> Result<SessionRecord, ManagedError> {
// #3823: guarantee the tmux SERVER exists before the FIRST tmux call
// this flow issues (`resolve_session_name`'s name-collision
// `list-sessions` probe, below) — on a machine where tmux has never
// run, that probe is what used to 500 the whole create, long before
// the #3386/#3722 choke point in `create_with_resolved_name` would
// have started it. On the (common) case where the server is already
// up, `tmux start-server` is a cheap no-op round-trip — redundant
// with the pre-existing #3386/#3722 `ensure_server_up` call inside
// `core::tmux::create_managed_session` (reached a few calls later
// via `create_with_resolved_name` → `self.tmux.create_session()`),
// but accepted: correctness (never racing the FIRST tmux call
// against an absent socket) outweighs the cost of one extra
// idempotent subprocess spawn per session create.
self.tmux.ensure_server_up()?;
// Never run (and therefore never recursively watch/scan) a managed
// session in `$HOME` or a TCC-protected folder — that triggers a cascade
// of macOS "trusty-mpm would like to access …" folder prompts (#2758).
// A missing or unsafe cwd resolves to a neutral scratch dir, never $HOME.
let cwd = crate::core::protected_dirs::safe_session_cwd(cwd);
let github_project = repo_url
.as_deref()
.and_then(trusty_common::github_path::parse_github_path)
.map(|gh| gh.repo);
let tmux_name = self
.resolve_session_name(
name_hint.as_deref(),
github_project.as_deref(),
&cwd,
|_| false,
)
.await?;
self.create_with_resolved_name(
id,
tmux_name,
task,
cwd,
workspace_path,
repo_url,
branch,
runtime,
ephemeral,
owned,
)
.await
}
/// Create a new managed session using a PRE-RESOLVED, pre-reserved tmux
/// session name (issue #2032).
///
/// Why: the in-project spawn path (`daemon::managed_routes::inproject`)
/// must name the per-session git worktree/branch after the SEMANTIC tmux
/// name rather than the raw session UUID, and the worktree has to exist
/// BEFORE the tmux session does. That forces name resolution to happen
/// earlier than [`Self::create_with_id`] normally would derive it (via
/// [`SessionManager::resolve_session_name`], with the worktree-dir/branch
/// check folded in as an extra collision predicate). This method accepts
/// that already-reserved name directly instead of re-deriving it, so a
/// single session is never named twice.
/// What: identical to [`Self::create_with_id`] except `reserved_name` is
/// used verbatim as the tmux session name — no `name_hint`/`repo_url`/`cwd`
/// derivation runs here at all.
/// Test: exercised transitively by the in-project spawn integration tests;
/// `create_with_reserved_name_suffixes_stale_reservation` in
/// `super::naming_tests` (the #3692 stale-reservation safety net).
#[allow(clippy::too_many_arguments)]
pub(crate) async fn create_with_reserved_name(
&self,
id: ManagedSessionId,
reserved_name: String,
task: String,
cwd: Option<PathBuf>,
workspace_path: Option<PathBuf>,
repo_url: Option<String>,
branch: Option<String>,
runtime: crate::runtime::RuntimeKind,
ephemeral: bool,
owned: bool,
) -> Result<SessionRecord, ManagedError> {
// #3823: same server-up guarantee as `create_with_id` — this path's
// first tmux call is `create_with_resolved_name`'s
// `dedupe_session_name`, which also issues a raw `list-sessions`.
// Same accepted redundant-but-idempotent round-trip note as
// `create_with_id`'s call above: a no-op `tmux start-server` when
// the server is already up, still cheaper than risking the FIRST
// tmux call racing an absent socket.
self.tmux.ensure_server_up()?;
// Never run (and therefore never recursively watch/scan) a managed
// session in `$HOME` or a TCC-protected folder — that triggers a cascade
// of macOS "trusty-mpm would like to access …" folder prompts (#2758).
// A missing or unsafe cwd resolves to a neutral scratch dir, never $HOME.
let cwd = crate::core::protected_dirs::safe_session_cwd(cwd);
self.create_with_resolved_name(
id,
reserved_name,
task,
cwd,
workspace_path,
repo_url,
branch,
runtime,
ephemeral,
owned,
)
.await
}
/// Shared tail of [`Self::create_with_id`] / [`Self::create_with_reserved_name`]:
/// create the tmux host at an ALREADY-RESOLVED name and persist the record.
///
/// Why: extracting this (#2032) means the name-derivation callers and the
/// pre-reserved-name caller share one create+persist implementation instead
/// of two near-duplicate copies of the tmux-guard/correlation/upsert dance.
/// What: re-checks the resolved name for collision immediately before the
/// tmux create — auto-suffixing via
/// [`SessionManager::dedupe_session_name`], never rejecting (#3692) — then
/// creates the tmux session at `cwd` via the driver, arms a
/// [`super::session_guard::TmuxSessionGuard`] so a failed persist reaps the
/// orphaned session (#1452/#1453), builds the [`SessionRecord`] in state
/// `Provisioning`, and persists it. The dedupe is the create path's FINAL
/// safety net: `resolve_session_name`'s snapshot and this tmux create are
/// separated by real work (clone/worktree provisioning — the documented
/// TOCTOU window), and `create_with_reserved_name` trusts a name reserved
/// even earlier; re-checking here narrows that window to a few
/// instructions.
/// Residual (ACCEPTED, tracked as issue #3707 — #3698 round-2 decision):
/// the narrowed window is NOT closed, and because `create_session` runs
/// `tmux new-session -A -d`, two truly concurrent creators that compute
/// the SAME deduped name both "succeed" — `-A` silently ATTACHES the
/// loser to the winner's physical session and two records get persisted
/// aliasing one pane. A rename-style re-verify-under-lock cannot fix
/// this here: the loser never created anything it may safely destroy,
/// and the armed [`super::session_guard::TmuxSessionGuard`] below would
/// reap the WINNER's live session on a detected conflict. The real fix
/// (fail-loud non-`-A` create + guard-aware retry) is scoped in #3707.
/// Test: `create_with_reserved_name_suffixes_stale_reservation` in
/// `super::naming_tests`; covered transitively by every
/// `create_with_id`/`create_with_reserved_name` test.
#[allow(clippy::too_many_arguments)]
async fn create_with_resolved_name(
&self,
id: ManagedSessionId,
tmux_name: String,
task: String,
cwd: PathBuf,
workspace_path: Option<PathBuf>,
repo_url: Option<String>,
branch: Option<String>,
runtime: crate::runtime::RuntimeKind,
ephemeral: bool,
owned: bool,
) -> Result<SessionRecord, ManagedError> {
// #3692 final safety net: the name was resolved/reserved some time ago
// (clone + worktree provisioning may have run in between) — re-check
// and auto-suffix at the last moment before the tmux session springs
// into existence, so a reservation gone stale never yields a second
// live session under an already-taken name.
let resolved_name = tmux_name;
let tmux_name = self.dedupe_session_name(&resolved_name, None).await?;
if tmux_name != resolved_name {
warn!(
id = %id,
requested = %resolved_name,
actual = %tmux_name,
"resolved/reserved session name went stale before the tmux create; auto-suffixed (#3692)"
);
}
let workdir = cwd.to_string_lossy().to_string();
self.tmux
.create_session(&tmux_name, &workdir)
.map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
// Capture the freshly-created pane's stable tmux `pane_id` (#2453
// review finding 1, round 2) — best-effort; `None` when the driver
// does not support it (fake drivers in tests) or the call fails.
// This is the ONLY signal the bare-`tm` in-pane relaunch's
// nested-session guard can trust to confirm pane-level identity; a
// session-scoped env var is inherited by sibling panes/windows and
// therefore insufficient (see `SessionRecord::pane_id`'s doc).
let pane_id = self.tmux.get_pane_id(&tmux_name);
// OWN the freshly-created tmux session immediately (#1453). From here
// until the record is durably persisted, this guard is the session's
// sole owner: any early return (e.g. a failing `store.upsert`) drops the
// guard and reaps the otherwise-orphaned tmux session. Ownership is
// transferred to the persistent store via `disarm` only AFTER upsert
// succeeds — closing the window that let 159 orphans accumulate (#1452).
let mut tmux_guard =
super::session_guard::TmuxSessionGuard::new(tmux_name.clone(), self.tmux.clone());
// Seed the session↔artifact correlation from what we know at creation
// time (worktree path + branch). PR / issue ids accrue later as the
// driver pushes work and opens a PR.
let mut correlation = crate::driver::SessionCorrelation::new();
if let Some(ref ws) = workspace_path {
correlation = correlation.with_worktree(ws.clone());
}
if let Some(ref b) = branch {
correlation = correlation.with_branch(b.clone());
}
let record = SessionRecord {
id,
tmux_name: tmux_name.clone(),
cwd,
task,
state: ManagedSessionState::Provisioning,
created_at: Utc::now(),
last_activity_at: None,
workspace_path,
repo_url,
branch,
pending_decision: None,
proposed_default: None,
correlation,
runtime,
ephemeral,
// Ownership is set atomically at creation: `true` ONLY when the SM
// provisioned the workspace via git clone; local-path spawn (#1502),
// adopt (#1433), and reconcile all pass `false` (safe default —
// prefer NOT deleting over accidental deletion).
workspace_owned: owned,
// source_id is set post-creation by the in-project spawn path
// (via SessionManager::set_source_id); this constructor never
// knows the source project — it is set by the caller.
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id,
injection_status: Default::default(),
// #3649: owner-unknown by default. The two real provisioning call
// sites (`spawn_managed_cloned`, `spawn_managed_inproject` in
// `daemon::managed_routes::lifecycle`) call
// `SessionManager::set_worktree_owner` immediately after this
// constructor returns, mirroring the existing `set_workspace_owned`
// post-creation-setter precedent rather than threading yet another
// parameter through every `create_with_id`/`create_with_reserved_name`
// call site.
worktree_owner: None,
terminal_at: None,
};
// Persist the record. On failure the freshly-created tmux session has
// NO registry owner — the armed `tmux_guard` above reaps it on the early
// return below, preventing the orphan (#1457). The reap itself happens
// silently in the guard's `Drop`; log here first so the rollback is
// visible in the daemon's stderr logs alongside the other lifecycle
// events (never stdout — that carries MCP JSON-RPC framing).
if let Err(e) = self.store.write().await.upsert(record.clone()).await {
warn!(
id = %id,
name = %tmux_name,
"registry upsert failed; rolling back (reaping) the orphaned tmux session: {e}"
);
// Returning here drops the still-armed `tmux_guard`, which kills the
// session. The original store error propagates unchanged.
return Err(ManagedError::from(e));
}
// The record is durably persisted; the store/registry now owns the
// session's lifetime. Disarm so the guard's drop is a no-op and the
// tracked session is NOT reaped at the end of this request scope (#1453).
tmux_guard.disarm();
info!(id = %id, name = %tmux_name, runtime = %runtime.as_str(), "managed session created");
Ok(record)
}
}