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
//! Managed-spawn POST + live provisioning progress for the guided `tm` on-ramp
//! (#1904, async rework #2605).
//!
//! Why: "launch new" in the guided default provisions a workspace (git clone /
//! `clone --bare` + worktree + agent/skill deploy) which, on a large repo,
//! outlasts the CLI's HTTP timeout — the original synchronous POST failed with
//! `operation timed out` even though the daemon kept working (#2605). This
//! module now drives the ASYNC spawn path: it POSTs `background: true`, gets a
//! job id back immediately, and POLLS `GET .../{id}/provision-status`,
//! upgrading a `trusty_progress` spinner with the live phase (and clone
//! percent) until the session is ready — then attaches. The connection is
//! never held open across the clone, so it cannot time out.
//! What: [`launch_new_session_and_attach`] (POST-background → poll → attach),
//! [`spawn_progress_message`] (initial spinner text), and
//! [`provisioning_message`] (the pure phase/detail → spinner-line formatter).
//! Test: `spawn_progress_message_first_run`, `spawn_progress_message_reuse`,
//! `provisioning_message_*` here; `launch_new_session_and_attach` is covered by
//! the `POST /api/v1/sessions/managed` + `provision-status` integration tests.
use std::time::{Duration, Instant};
use anyhow::Context as _;
use serde::Deserialize;
use super::first_run::needs_first_run_clone;
use super::picker_launch_new::LaunchIsolation;
use super::tmux_attach::AttachOutcome;
/// How long to wait between `provision-status` polls.
///
/// Why: fast enough that the spinner feels live, slow enough that a minutes-long
/// clone does not hammer the daemon with hundreds of requests.
const POLL_INTERVAL: Duration = Duration::from_millis(600);
/// Overall ceiling on how long we follow a background provision before giving
/// up on the CLI side (the daemon keeps working regardless).
///
/// Why: requirement #3 — the CLI must not wait forever, but the bound has to be
/// generous enough for a cold clone of a very large repo on a slow network.
/// Thirty minutes is far beyond any legitimate provision yet still finite; on
/// expiry the CLI prints an actionable message rather than hanging.
const PROVISION_DEADLINE: Duration = Duration::from_secs(30 * 60);
/// Ack shape for `POST /api/v1/sessions/managed` — covers BOTH the async `202`
/// (`{ id, state: "provisioning" }`) and a synchronous `201` (`{ id, name,
/// state, … }`) from an older daemon that ignores `background`.
///
/// Why: a local type avoids depending on the daemon's internal DTO from the
/// CLI binary crate; `id`/`name`/`state` are all we need to branch on.
/// Test: covered indirectly by `launch_new_session_and_attach`.
#[derive(Debug, Default, Deserialize)]
struct SpawnAck {
#[serde(default)]
id: String,
#[serde(default)]
name: String,
#[serde(default)]
state: String,
}
/// Poll shape for `GET /api/v1/sessions/managed/{id}/provision-status`.
///
/// Why: mirrors `daemon::managed_routes::ProvisionStatusResponse` for the
/// fields the guided flow renders and branches on.
/// Test: covered indirectly by `launch_new_session_and_attach`; the render
/// formatting is covered by `provisioning_message_*`.
#[derive(Debug, Default, Deserialize)]
struct ProvisionStatus {
#[serde(default)]
state: String,
#[serde(default)]
label: Option<String>,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
error: Option<String>,
}
/// Build the spinner message shown while the managed-spawn POST is in flight
/// (issue #1904).
///
/// Why: a first-run clone triggers minutes of server-side work; the operator
/// needs a distinct message for that case versus the (much faster) worktree-
/// reuse launch, without duplicating the formatting at each call site.
/// What: returns the first-run "cloning into <path>…" message when `first_run`
/// is `Some`, else the generic "launching new session…" message.
/// Test: `spawn_progress_message_first_run`, `spawn_progress_message_reuse`.
pub(crate) fn spawn_progress_message(first_run: Option<&(String, std::path::PathBuf)>) -> String {
match first_run {
Some((proj, path)) => format!(
"tm: first run for {proj} — cloning into {}, this may take a few minutes…",
path.display()
),
None => "tm: launching new session…".to_string(),
}
}
/// Format the live spinner line from a poll snapshot's phase label + detail.
///
/// Why: keeps the "provisioning workspace: <phase> — <detail>" rendering a
/// single pure function so it is unit-testable and cannot drift between call
/// sites.
/// What: `"tm: provisioning workspace: <label>"`, appending `" — <detail>"`
/// when a fine-grained detail (e.g. clone percent) is present. Falls back to a
/// generic phrase when the daemon has not reported a label yet.
/// Test: `provisioning_message_label_only`, `provisioning_message_label_and_detail`,
/// `provisioning_message_no_label`.
fn provisioning_message(label: Option<&str>, detail: Option<&str>) -> String {
match (label, detail) {
(Some(l), Some(d)) => format!("tm: provisioning workspace: {l} — {d}…"),
(Some(l), None) => format!("tm: provisioning workspace: {l}…"),
(None, _) => "tm: provisioning workspace…".to_string(),
}
}
/// POST a new managed session to the daemon (async) and attach once ready.
///
/// Why: "launch new" must go through the daemon's managed spawn path rather
/// than deploying framework files itself (#1724) AND must not hold one HTTP
/// request open across a multi-minute clone (#2605). This POSTs
/// `background: true`, then polls `provision-status`, rendering live phase +
/// clone-percent progress, before attaching.
///
/// What that path does with a LOCAL `repo_url` changed under this comment and
/// the old wording — "never write framework files into the live checkout" — is
/// no longer true of it. ADR-0037 made a local working-tree root run on that
/// checkout by default, and `spawn_managed_on_main` deploys `.claude/` and the
/// bundled skills into it — but never `TASK.md`, which #7879 confined to a
/// provisioned worktree. That is permitted configuration under ADR-0044, which restricts the same checkout to documents and
/// configuration and nothing else. #1724's actual guarantee — that the
/// daemon-unreachable FALLBACK never deploys into a checkout it was not told
/// it could — lives in `provision_for_fallback` and is unchanged.
/// What: (1) shows a `trusty_progress` spinner (auto-hidden in non-TTY/plain
/// output); (2) POSTs `{ repo_url, ref: "HEAD", task: "", force_new: true,
/// background: true }`, plus `name_hint` when the caller supplied one (#4965 —
/// the picker's `n <name>`, ALREADY kebab-cased by
/// `trusty_common::session_naming::leaf_slug_from_hint` so the daemon's own
/// sanitization is a no-op; the key is omitted entirely for `None`, so the
/// unnamed path's wire shape is unchanged), plus `worktree: true` when
/// `isolation` is [`LaunchIsolation::OwnWorktree`] (#5773 — `n <name>
/// --worktree`; the key is likewise omitted otherwise, so ADR-0037's
/// `#[serde(default)]` main-checkout default still applies); (3) if the daemon answered
/// synchronously (`201` with a
/// name — an older daemon ignoring `background`) attaches immediately; (4)
/// otherwise polls `GET .../{id}/provision-status` every [`POLL_INTERVAL`] up
/// to [`PROVISION_DEADLINE`], upgrading the spinner via [`provisioning_message`]
/// until `state` is `ready` (attach `name`) or `failed` (surface `error`); a
/// transient poll error is retried within the deadline. `repo_url` MUST be the
/// git working-tree root so the daemon sets `source_id` correctly. Returns the
/// [`AttachOutcome`] from the terminal hand-off (#2678) — the picker's
/// `run_tty_picker` loop uses it to decide whether it must stop reading
/// stdin (a `switch-client` handoff, or a fail-closed skip, means this
/// pane is no longer the one the operator sees).
/// Test: `launch_new_session_and_attach_sends_the_name_hint` and
/// `launch_new_session_and_attach_omits_name_hint_when_unnamed` (in
/// `session::start_tests`) pin the `name_hint` key on the wire; the rest is
/// covered by the `POST /api/v1/sessions/managed` + `provision-status`
/// integration tests; the message formatting by `spawn_progress_message_*` and
/// `provisioning_message_*`.
pub(crate) async fn launch_new_session_and_attach(
daemon: &trusty_mpm::client::DaemonClient,
repo_url: &str,
name_hint: Option<&str>,
isolation: LaunchIsolation,
) -> anyhow::Result<AttachOutcome> {
let first_run = needs_first_run_clone(repo_url);
let progress_output = trusty_progress::Output::to_stderr();
let spinner = trusty_progress::ProgressHandle::spinner(
&progress_output,
spawn_progress_message(first_run.as_ref()),
);
let mut body = serde_json::json!({
"repo_url": repo_url,
"ref": "HEAD",
"task": "",
// #2450: the picker's EXPLICIT "launch new session" choice — force a
// fresh session so the daemon's in-project reconnect pre-flight
// (#1707) never adopts an unrelated live session for this project.
"force_new": true,
// #2605: provision asynchronously — the POST returns a job id
// immediately and we poll for progress, so a large-repo clone can
// never outlast the client's HTTP timeout.
"background": true,
});
// #4965: added only when the operator named the session (`n <name>`) —
// `body` is a literal object, so the index-assign always inserts. Omitting
// the key for `None` keeps the unnamed request byte-identical to what
// `session::start`'s wire-shape tests already pin.
if let Some(hint) = name_hint {
body["name_hint"] = serde_json::Value::String(hint.to_string());
}
// #5773: the picker's `--worktree` travels on the SAME `worktree` key
// `tm launch --worktree` sets — ADR-0037 decision 1 makes that explicit
// launch-time request the one input deciding placement, and one key with
// one spelling is what keeps the two surfaces from drifting. Omitted
// entirely when nothing was asked for, so the default launch's wire shape
// stays byte-identical to what `session_start_posts_the_same_wire_shape_…`
// pins.
if isolation.requests_worktree() {
body["worktree"] = serde_json::Value::Bool(true);
}
let send_result = daemon
.post("/api/v1/sessions/managed")
.json(&body)
.send()
.await;
let resp = match send_result {
Ok(resp) => resp,
Err(err) => {
spinner.finish_and_clear();
return Err(err).context("managed spawn POST failed");
}
};
if !resp.status().is_success() {
let status = resp.status();
spinner.finish_and_clear();
anyhow::bail!("daemon returned {status} for managed spawn");
}
let ack: SpawnAck = match resp.json().await {
Ok(body) => body,
Err(err) => {
spinner.finish_and_clear();
return Err(err).context("failed to parse managed spawn response");
}
};
// Synchronous completion (older daemon that ignored `background`): the ack
// already carries the final name/state — attach without polling.
if ack.state != "provisioning" && !ack.name.is_empty() {
spinner.finish_and_clear();
if first_run.is_some() {
eprintln!("tm: clone complete — workspace ready");
}
eprintln!("tm: session '{}' created ({})", ack.name, ack.state);
return crate::commands::tmux_attach::tmux_attach(&ack.name);
}
if ack.id.is_empty() {
spinner.finish_and_clear();
anyhow::bail!("daemon accepted the spawn but returned no job id to poll");
}
// Async path: follow the background provision to completion.
let name = match poll_until_ready(daemon, &ack.id, &spinner).await {
Ok(name) => name,
Err(err) => {
spinner.finish_and_clear();
return Err(err);
}
};
spinner.finish_and_clear();
if first_run.is_some() {
eprintln!("tm: clone complete — workspace ready");
}
eprintln!("tm: session '{name}' created (active)");
crate::commands::tmux_attach::tmux_attach(&name)
}
/// Poll `provision-status` until the background job is ready, rendering live
/// progress into `spinner`; returns the ready session's tmux name.
///
/// Why: the whole async fix — the CLI follows the daemon's background provision
/// via short, bounded poll requests instead of one long-held connection.
/// What: loops every [`POLL_INTERVAL`] until [`PROVISION_DEADLINE`]. On each
/// poll: `ready` → returns the `name` (falling back to `session_id`); `failed`
/// → returns an error carrying the daemon's `error`; `provisioning` → updates
/// the spinner via [`provisioning_message`]; a transient request/parse error is
/// tolerated and retried until the deadline. On deadline expiry returns an
/// actionable error (the session may still be provisioning server-side).
/// Test: covered by the `provision-status` integration tests.
async fn poll_until_ready(
daemon: &trusty_mpm::client::DaemonClient,
id: &str,
spinner: &trusty_progress::ProgressHandle,
) -> anyhow::Result<String> {
let status_url = format!("/api/v1/sessions/managed/{id}/provision-status");
let start = Instant::now();
loop {
if start.elapsed() > PROVISION_DEADLINE {
anyhow::bail!(
"provisioning did not finish within {} minutes; it may still be running on the \
daemon — check `tm sessions ls`",
PROVISION_DEADLINE.as_secs() / 60
);
}
match daemon.get(&status_url).send().await {
Ok(resp) if resp.status().is_success() => {
let status: ProvisionStatus = resp.json().await.unwrap_or_default();
match status.state.as_str() {
"ready" => {
return status
.name
.or(status.session_id)
.filter(|s| !s.is_empty())
.context("daemon reported ready but returned no session name");
}
"failed" => {
anyhow::bail!(
"provisioning failed: {}",
status.error.as_deref().unwrap_or("unknown error")
);
}
_ => {
spinner.set_message(provisioning_message(
status.label.as_deref(),
status.detail.as_deref(),
));
}
}
}
// A transient poll failure (daemon briefly busy, mid-restart) is not
// fatal — keep polling until the deadline.
_ => {}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawn_progress_message_first_run() {
let msg = spawn_progress_message(Some(&(
"acme/widgets".to_string(),
std::path::PathBuf::from("/tmp/ws"),
)));
assert!(msg.contains("first run for acme/widgets"));
assert!(msg.contains("/tmp/ws"));
}
#[test]
fn spawn_progress_message_reuse() {
let msg = spawn_progress_message(None);
assert_eq!(msg, "tm: launching new session…");
}
#[test]
fn provisioning_message_label_only() {
assert_eq!(
provisioning_message(Some("Cloning repository"), None),
"tm: provisioning workspace: Cloning repository…"
);
}
#[test]
fn provisioning_message_label_and_detail() {
assert_eq!(
provisioning_message(Some("Cloning repository"), Some("Receiving objects: 63%")),
"tm: provisioning workspace: Cloning repository — Receiving objects: 63%…"
);
}
#[test]
fn provisioning_message_no_label() {
assert_eq!(
provisioning_message(None, Some("ignored")),
"tm: provisioning workspace…"
);
}
}