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
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
//! Guided-picker resume/restart flow (#1742, #2001, #3531).
//!
//! Why: resuming a managed session from the bare-`tm` picker must handle every
//! runtime state without ever exposing a raw tmux failure. A live session is
//! attached directly; a Stopped/Errored session is restarted through the daemon;
//! and a *zombie* — the daemon still marks it active/provisioning but its tmux
//! pane has vanished (e.g. after a reboot) — is now auto-reconciled: the CLI
//! stops it (resetting the record to Stopped) then restarts it via the same
//! path, so the operator does nothing (#2001). Extracted from `guided.rs` to keep
//! both files under the 500-SLOC production cap.
//!
//! #3531: the zombie/restart classification below MUST run on
//! [`trusty_mpm::client::ManagedSessionSummary::persisted_state`], never the
//! plain `state` field. #3302 made the list/get endpoints reconcile the
//! DISPLAYED `state` against live tmux (so a running session never reads
//! `(stopped)`), which — as an unintended side effect — collapses an
//! `active`-but-tmux-dead zombie down to the SAME `"stopped"` string a
//! genuinely stopped session shows. Classifying off that display value made
//! `is_zombie` unable to tell the two apart, so the interactive picker's
//! "restart (stopped)" selection fell through to a plain restart that the
//! daemon's `/resume` (which validates the REAL persisted state) rejected
//! with a 409 ("cannot resume a session in state 'active'") — the exact
//! reported dead-end. `persisted_state` is the raw, un-reconciled value the
//! daemon captures before any display reconciliation can touch it, so
//! classification here always agrees with what `/resume` will actually check.
//!
//! What: [`plan_resume`] is the pure branch selector over [`is_zombie`] /
//! [`needs_restart`]; [`resume_session`] is the I/O driver that dispatches on
//! it, delegating the two daemon round-trips to [`reconcile_zombie_stop`]
//! (POST `/runtime-stop`) and [`restart_via_daemon`] (POST `/resume`).
//! [`resume_guided_session`] is a thin always-attach wrapper over
//! [`resume_session`] for the picker's own call sites, which are already
//! TTY-gated upstream (`guided::tty_gate`). `tm session(s) resume <id>`
//! (`commands::managed::session_resume`, #2649) calls [`resume_session`]
//! directly with `no_attach` derived from `std::io::stdin().is_terminal()`,
//! so a headless/scripted invocation still gets the daemon-side restart but
//! never attempts a real tmux attach with no controlling terminal to move
//! (code-critic HIGH, #2649 review).
//!
//! Test: the pure seams (`needs_restart`, `is_zombie`, `plan_resume`) are
//! exhaustively unit-tested in `tests_behavior_c_tests.rs`
//! (`guided_resume_plan_resume_prefers_persisted_state_over_display_state`
//! covers the #3531 fix specifically); the HTTP paths are exercised by the
//! e2e suite and manual smoke tests; the `no_attach` gate is covered by
//! `managed_tests.rs`'s
//! `session_resume_headless_active_live_tmux_skips_restart_and_attach`.
use Context as _;
use cratetmux_has_session;
use AttachOutcome;
/// Decide whether a guided resume must restart the session through the daemon.
///
/// Why: a stopped/errored managed session has no live tmux session; calling
/// `tmux attach-session` against it fails with "can't find session" (#1742).
/// The check is state-only: the daemon's `resume` endpoint only accepts
/// Stopped/Errored, so tmux liveness is NOT used here — see [`is_zombie`] for
/// the active-but-tmux-absent case.
/// What: returns `true` when `state` is `"stopped"` or `"errored"`. Returns
/// `false` for all other states (active, provisioning, decommissioned, …).
/// Test: `guided_resume_needs_restart_*` in `tests_behavior_c_tests.rs`.
pub
/// Detect a zombie session: daemon thinks it is active but the tmux session is gone.
///
/// Why: when a session's daemon state is NOT stopped/errored (i.e. active or
/// provisioning) but its tmux session has disappeared (e.g. after a machine
/// reboot before the daemon reconcile runs), the daemon's `resume` endpoint
/// would return 409 (can only resume Stopped/Errored). Rather than a dead end,
/// the CLI auto-reconciles: it stops the session (resetting the daemon record to
/// Stopped) then restarts it via the normal resume path — the operator does
/// nothing (#2001). Previously this was operator-driven (`tm session stop` then
/// `tm` again); that manual step is now automated.
/// What: returns `true` when `!needs_restart(state)` AND `!tmux_live` AND the
/// state is NOT terminal — a `decommissioned`/`deleted` tombstone is gone for
/// good and must never be treated as a resurrectable zombie (code-critic
/// CRITICAL: `is_zombie("deleted", false)` used to return `true`, which routed
/// a deleted record through `ReconcileThenRestart` and resurrected it).
/// Test: `guided_resume_is_zombie_*`, `is_zombie_false_for_terminal_states` in
/// `tests_behavior_c_tests.rs`.
pub
/// Whether a wire state token is a TERMINAL tombstone (`decommissioned`/`deleted`).
///
/// Why: the resume decision (`plan_resume`) must refuse a terminal record
/// outright — never attach into it, never reconcile-restart it. Driving this
/// off [`ManagedSessionState::is_terminal`] (the enum, the single source of
/// truth) rather than a hardcoded token list means a new terminal variant can
/// never silently slip past the resume guard.
/// What: `true` iff `state` parses to a terminal [`ManagedSessionState`].
/// Test: `plan_resume_refuses_terminal_states` in `tests_behavior_c_tests.rs`.
/// The action a guided resume must take, derived purely from state + tmux liveness.
///
/// Why: extracting the branch selection from [`resume_guided_session`] into a
/// pure function makes the control flow — including the new zombie
/// auto-reconcile path (#2001) — unit-testable without a daemon, tmux, or HTTP.
/// The I/O driver simply matches on the returned variant.
/// What: four variants cover every case bare `tm` can hand a resume:
/// • [`ResumeAction::InPlace`] — bare `tm` is running INSIDE the pane of the
/// managed session named by `TM_MANAGED_SESSION_ID`, whose runtime already
/// exited (#2023 A/B); relaunch it in place via direct `exec`, never the
/// daemon kill+recreate path (#2023 C). Takes priority over everything
/// below — see [`try_inplace_relaunch`].
/// • [`ResumeAction::Attach`] — a live runtime; attach directly.
/// • [`ResumeAction::Restart`] — Stopped/Errored; POST `/resume` then attach.
/// • [`ResumeAction::ReconcileThenRestart`] — zombie (active/provisioning but
/// tmux gone); POST `/runtime-stop` to reset the record to Stopped, THEN
/// POST `/resume` then attach.
/// Test: `guided_resume_plan_*` in `tests_behavior_c_tests.rs`;
/// `plan_inplace_*` in this module.
pub
/// Decide, purely from `state` and `tmux_live`, how a guided resume should proceed.
///
/// Why: single source of truth for the resume branch selection so the zombie
/// reconcile, the plain restart, and the happy-path attach can never diverge
/// from what [`is_zombie`]/[`needs_restart`] classify. Pure so it is exhaustively
/// unit-tested.
/// What: TERMINAL (`decommissioned`/`deleted`, checked FIRST) → `Terminal`
/// (refuse); zombie (checked next, since a zombie's state also fails
/// `needs_restart`) → `ReconcileThenRestart`; Stopped/Errored → `Restart`;
/// everything else (active/provisioning with a live tmux) → `Attach`. The
/// terminal check is first so neither the zombie path (would resurrect via
/// reconcile-restart) nor the Attach path (would attach into a deleted session)
/// can ever be reached for a tombstone (code-critic CRITICAL).
/// Test: `guided_resume_plan_*`, `plan_resume_refuses_terminal_states` in
/// `tests_behavior_c_tests.rs`.
/// Resolve the state string [`plan_resume`] must classify from a wire
/// [`trusty_mpm::client::ManagedSessionSummary`] (#3531).
///
/// Why: extracting this one-line lookup into its own pure, testable seam
/// pins the exact bug — `resume_session` used to pass `&session.state` (the
/// possibly display-reconciled value, #3302) straight into `plan_resume`,
/// which silently misclassified a zombie session (`active`/`provisioning`
/// with tmux gone) as a plain `Stopped` restart the moment the list/get
/// endpoint had already reconciled its `state` field to `"stopped"` for
/// display. See the module doc for the full chain.
/// What: `session.persisted_state`, when the daemon sent it; falls back to
/// `session.state` when talking to an older daemon that predates the field
/// (`persisted_state` is `None`) — reproducing the pre-#3531 behavior rather
/// than panicking or guessing.
/// Test: `resume_classification_state_prefers_persisted_state`,
/// `resume_classification_state_falls_back_to_display_state_when_absent` in
/// `tests_behavior_c_tests.rs`.
pub
pub
/// Restart (if needed) then attach to a managed session from the guided picker.
///
/// Why: the guided picker must handle stopped sessions gracefully (#1742). A
/// direct `tmux attach-session` against a stopped session exits with failure
/// ("can't find session"). This function routes the request correctly based on
/// session state and tmux liveness, surfaces clear actionable messages for all
/// failure modes, and never exposes a raw tmux failure to the operator.
/// What: (1) `tmux_has_session` checks liveness and [`plan_resume`] selects the
/// branch; (2) for a zombie (active/provisioning but tmux gone) it AUTO-STOPS via
/// [`reconcile_zombie_stop`] — POST `/runtime-stop` resets the daemon record to
/// Stopped — then falls through into the same restart path, so the operator does
/// nothing (#2001); (3) for stopped/errored (or a just-reconciled zombie) it warns
/// about pane kill if the tmux pane is still live, then POSTs
/// `/api/v1/sessions/managed/{id}/resume` via [`restart_via_daemon`] with a
/// 30-second timeout; (4) 404/409/422/5xx/network errors each print a distinct
/// actionable message (the 422/5xx branches surface the daemon body verbatim so
/// a removed-workspace reason reaches the operator, #2577); (5) on HTTP 200, the
/// response body is checked — if
/// `state=errored` the runtime spawn failed and the operator is directed to
/// `tm session info`; (6) falls through to `tmux_attach` only when everything is
/// confirmed ready.
/// Test: `needs_restart`, `is_zombie`, and `plan_resume` are the testable pure
/// seams; the I/O path is exercised by the e2e suite and manual smoke tests.
/// Returns the [`AttachOutcome`] from the terminal hand-off (#2678) so
/// `run_tty_picker` knows whether it must stop reading stdin.
pub async
/// Same state machine as [`resume_guided_session`], but TTY-gated: skips the
/// final terminal hand-off (and prints a status line instead) when the caller
/// has no controlling terminal to move.
///
/// Why (code-critic HIGH, #2649 review): every branch of the resume flow
/// unconditionally ended in [`tmux_attach`], which shells out to a REAL
/// `tmux attach-session`/`switch-client`. That is correct for the interactive
/// picker (always run at a real terminal — gated upstream, see
/// [`resume_guided_session`]'s doc) but wrong for the explicit `tm
/// session(s) resume <id>` verb: that verb is also a legitimate
/// scripted/headless entry point (CI, a supervisor process, a piped
/// invocation) with no controlling terminal at all. Attaching there is at
/// best a silent no-op and at worst an actively confusing tmux error —
/// even though the daemon-side resume already succeeded. Mirrors the
/// `tty_gate` idiom (`guided.rs`, `std::io::stdin().is_terminal()`), but
/// since the gated action here is an OUTPUT step (attach) rather than an
/// input read, the caller passes the already-resolved `no_attach` bool
/// instead of this function reading `stdin` itself — keeping the gate at the
/// actual I/O boundary and this function fully testable without a TTY.
/// What: identical branch selection to `resume_guided_session`
/// (`plan_resume` / zombie-reconcile / restart via `restart_via_daemon`).
/// When `no_attach` is `true`, skips [`tmux_attach`] and instead prints the
/// same `resumed <name> (<id>) [<state>]` status line the pre-#2649
/// direct-POST implementation printed — using the daemon's authoritative
/// post-restart record when a restart happened, or the caller's own summary
/// otherwise (the plain `Attach` branch never touches the daemon, so nothing
/// changed). Returns `Ok(())` in both cases once the daemon-side state is
/// confirmed good — a headless caller only sees a non-zero exit when the
/// daemon-side resume/reconcile itself failed. When `no_attach` is `true`
/// the return is always [`AttachOutcome::Skipped`] (#2678) — no attach was
/// attempted, so a looping caller has nothing to guard against; when
/// `no_attach` is `false` the return is whatever [`tmux_attach`] resolved
/// (`Attached`, `Switched`, or `TargetUnresolved`).
/// Test: `needs_restart`/`is_zombie`/`plan_resume` are the testable pure
/// seams (`guided_resume_plan_active_live_tmux_attaches` proves the Attach
/// branch selection this depends on); `managed_tests.rs`'s
/// `session_resume_headless_active_live_tmux_skips_restart_and_attach`
/// (#2649) drives this function's Attach branch end-to-end in a non-TTY test
/// process and asserts no daemon-side mutation occurred; the restart I/O
/// path is exercised by the e2e suite and manual smoke tests.
pub async
/// Auto-stop a zombie session so the daemon record resets to `Stopped` (#2001).
///
/// Why: the daemon's `/resume` only accepts Stopped/Errored; a zombie's record is
/// still active/provisioning, so it must be stopped first. `/runtime-stop` marks
/// the record `Stopped` synchronously (keeping the workspace), which then
/// satisfies the restart path. This is the automation of the old manual
/// `tm session stop <id>` step.
/// What: POSTs `/api/v1/sessions/managed/{id}/runtime-stop` with a 30-second
/// timeout. Network/404/other-error each print a distinct actionable message and
/// bail — a failed stop is the one case that still needs a human. Success returns
/// `Ok(())` and the caller proceeds to the restart path.
/// Test: I/O path exercised by the e2e suite; the branch selection is the pure
/// [`plan_resume`] seam.
async
/// POST `/resume` for a Stopped/Errored (or just-reconciled) session and validate.
///
/// Why: the restart round-trip is shared by the plain stopped/errored path and
/// the zombie auto-reconcile path (#2001); a single helper keeps their error
/// handling identical. Extracting it also keeps [`resume_guided_session`] small.
/// What: POSTs `/api/v1/sessions/managed/{id}/resume` with a 30-second timeout so
/// a hung daemon cannot freeze the CLI. 404/409/422/5xx/other/network errors each
/// print a distinct actionable message and bail; the 422/5xx/other branches now
/// surface the daemon's response body verbatim (#2577) so an operator sees the
/// concrete reason (e.g. "workspace directory … no longer exists") instead of a
/// bare status code. On HTTP 200 the body is parsed
/// and, if `state=errored`, the async runtime spawn failed → bail directing the
/// operator to `tm session info`. Success prints "session restarted." and
/// returns the daemon's authoritative post-restart [`ManagedSessionSummary`]
/// (#2649 review: the caller needs this to print an accurate final state in
/// headless/`no_attach` mode, rather than assuming `state == "active"`); the
/// interactive caller uses it to attach.
/// Test: I/O path exercised by the e2e suite; branch selection is [`plan_resume`].
async
/// Cap an echoed daemon error body at a sane display length.
///
/// Why: a daemon error body is normally a short, hand-written message, but
/// nothing on the wire enforces that — a future error path (or a proxy
/// mangling the response) could hand the CLI an unbounded string. Printing it
/// straight to the terminal without a cap risks flooding the operator's
/// scrollback for what should be a one-line diagnostic.
/// What: returns `s` unchanged when it fits within 2000 chars; otherwise
/// truncates to the first 2000 chars (on a `char` boundary) and appends a
/// `… (truncated)` marker.
/// Test: `truncate_for_display_leaves_short_bodies_unchanged`,
/// `truncate_for_display_caps_long_bodies` in `tests_behavior_e_tests.rs`.
pub
/// Build the operator-facing remedy line for a 422 "session not resumable"
/// response, selected by the daemon's `x-trusty-resume-reason` header.
///
/// Why (#2577 review): a prior draft pointed EVERY 422 at the same generic
/// "delete it" hint, including `tm session rm <id>` — a subcommand that DOES
/// NOT EXIST (`tm session` only defines `delete`/`decommission`; running it
/// fails with "unrecognized subcommand 'rm'"). Worse, that draft conflated
/// `WorkspaceMissing` (the workspace is genuinely gone; `delete --force` is
/// safe — store-only, never touches tmux) with `PaneGone` (the tmux SESSION is
/// still alive via a sibling window; `decommission` KILLS THE WHOLE SESSION,
/// destroying that live sibling too) under one framing. Steering an operator
/// through the pane-gone case toward teardown risked destroying work in the
/// surviving window. This function is a pure, table-driven seam so its LITERAL
/// text can be pinned against `Cli::try_parse_from` in a unit test — the exact
/// bug class that shipped the nonexistent `rm` verb.
/// What: `Some("pane_gone")` → an inspect-before-acting remedy naming
/// `tmux list-panes` / `tm session info` and warning that `decommission` kills
/// the whole session; `Some("workspace_missing")` → `tm session delete <id>
/// --force` (safe: store-only, and there is no workspace left to protect);
/// anything else (an unrecognized or absent header — e.g. an older daemon that
/// predates #2577) → a conservative pointer naming no destructive verb at all.
/// Test: `unresumable_remedy_line_cites_real_subcommands` in
/// `tests_behavior_e_tests.rs`.
pub
/// Attach (or switch) the current terminal into tmux session `name`.
///
/// Why: resuming a session means handing the terminal over to tmux; when the
/// operator is already inside a tmux client, a plain `attach-session` is
/// refused by tmux (nesting guard), so the actual argv choice — and the
/// safe, explicit `-c <client>` targeting (#2678) — is delegated to the
/// shared [`crate::commands::tmux_attach::tmux_attach`] helper (#1873).
/// What: thin re-export so existing call sites don't need to change their import
/// path.
/// Test: `attach_argv_uses_attach_session`,
/// `switch_client_argv_targets_explicit_client` in `tmux_attach.rs` cover
/// the argv decision; exercised indirectly here by the picker flow.