Skip to main content

beam_worker/backend/herdr/
mod.rs

1//! Herdr first-class `SessionBackend`.
2//!
3//! Managed sessions occupy one labeled workspace (`beam-{sid8}`) on the
4//! shared Herdr default session; the root pane runs the existing launch spec
5//! (`env` / `systemd-run` + adapter argv). Adopted sessions observe and drive
6//! a user-owned pane without ever `pane run`-ing a second CLI.
7//!
8//! Control plane is CLI-first (`herdr …`, JSON stdout); the raw socket is
9//! only used for the long-lived observe stream. Input goes through
10//! `pane send-text` / `pane send-keys` — never `terminal session control`
11//! (that would steal the human TUI's input/resize) and never `agent.prompt`
12//! as the v1 primary path.
13
14use std::sync::Arc;
15use std::sync::Mutex as StdMutex;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use anyhow::{Context, Result, bail};
19use async_trait::async_trait;
20use tokio::sync::broadcast;
21use tracing::{info, warn};
22
23use beam_core::DEFAULT_TERMINAL_COLS;
24use beam_core::DEFAULT_TERMINAL_ROWS;
25
26use super::{RAW_INPUT_ENTER_DELAY, SessionBackend, SpawnOpts};
27
28pub(crate) mod cli;
29pub(crate) mod ids;
30pub(crate) mod observe;
31
32use cli::{
33    HERDR_SHELL_READY_TIMEOUT, pane_list, pane_process_info, pane_read_visible, pane_run,
34    pane_send_keys, pane_send_text, pane_wait_output, start_server, status_server, workspace_close,
35    workspace_create, workspace_get, workspace_get_ids, workspace_list,
36};
37use ids::{HerdrIds, command_string, workspace_by_label};
38
39/// Default shell-prompt regex shared by bash/zsh/sh/fish (tail prompt).
40pub(crate) const HERDR_SHELL_PROMPT_REGEX: &str = r"[\$#%] ?$";
41
42/// A managed or observed Herdr terminal identity. `ids` is populated by
43/// `spawn()` and read by `run_loop` for `Ready`; it lives behind a mutex so
44/// the backend can be shared as `Arc<dyn SessionBackend>`.
45#[derive(Debug)]
46pub struct HerdrBackend {
47    session_label: String,
48    cwd: String,
49    observe_cols: u16,
50    observe_rows: u16,
51    ids: StdMutex<Option<HerdrIds>>,
52    data_tx: broadcast::Sender<String>,
53    observe_started: AtomicBool,
54    observe_stop: Arc<AtomicBool>,
55}
56
57impl HerdrBackend {
58    pub fn new(session_label: String, cwd: String) -> Self {
59        let (data_tx, _) = broadcast::channel(512);
60        Self {
61            session_label,
62            cwd,
63            observe_cols: DEFAULT_TERMINAL_COLS,
64            observe_rows: DEFAULT_TERMINAL_ROWS,
65            ids: StdMutex::new(None),
66            data_tx,
67            observe_started: AtomicBool::new(false),
68            observe_stop: Arc::new(AtomicBool::new(false)),
69        }
70    }
71
72    /// Current herdr ids after a successful `spawn()`.
73    pub fn herdr_ids(&self) -> Option<HerdrIds> {
74        self.ids.lock().unwrap().clone()
75    }
76
77    fn set_ids(&self, ids: HerdrIds) {
78        *self.ids.lock().unwrap() = Some(ids);
79    }
80
81    fn start_observe(&self) {
82        if self.observe_started.swap(true, Ordering::SeqCst) {
83            return;
84        }
85        let (workspace_id, pane_id) = match self.herdr_ids().map(|i| i.workspace_pane()) {
86            Some(ids) => ids,
87            None => return,
88        };
89        let tx = self.data_tx.clone();
90        let stop = self.observe_stop.clone();
91        let cols = self.observe_cols;
92        let rows = self.observe_rows;
93        info!(workspace_id, pane_id, "starting herdr observe");
94        tokio::spawn(async move {
95            observe::run_herdr_observe(pane_id, cols, rows, tx, stop).await;
96        });
97    }
98
99    /// Ensure the shared herdr server is reachable, starting it headless if
100    /// needed. Fail-closed: a missing/unreachable server is a hard error for
101    /// managed spawn.
102    async fn ensure_server(&self) -> Result<()> {
103        if status_server().await.unwrap_or(false) {
104            return Ok(());
105        }
106        warn!("herdr server not running; starting headless server");
107        start_server().await?;
108        Ok(())
109    }
110
111    /// Label-deduped managed spawn: reuse an existing labeled workspace when
112    /// possible, otherwise create one and `pane run` the launch spec.
113    async fn managed_spawn(&self, bin: &str, args: &[String]) -> Result<()> {
114        self.ensure_server().await?;
115        let label = self.session_label.clone();
116        let entries = workspace_list().await?;
117        if let Some(existing) = workspace_by_label(&entries, &label) {
118            let existing_id = existing.workspace_id.clone();
119            match workspace_get(&existing_id).await {
120                Ok(Some(payload)) => {
121                    let ids = match workspace_get_ids(&payload).ok().flatten() {
122                        Some(ids) => ids,
123                        None => {
124                            // `workspace get` does not embed the root pane id on
125                            // real herdr; recover it from `pane list`.
126                            match self.pane_id_for_workspace(&existing_id).await {
127                                Ok(Some(pane_id)) => HerdrIds {
128                                    workspace_id: existing_id.clone(),
129                                    pane_id,
130                                },
131                                Ok(None) => bail!(
132                                    "herdr workspace {existing_id} (label {label}) exists but no pane was found; run /restart to recreate"
133                                ),
134                                Err(err) => {
135                                    return Err(err.context(format!(
136                                    "herdr workspace {existing_id} (label {label}) pane lookup failed"
137                                )));
138                                }
139                            }
140                        }
141                    };
142                    self.set_ids(ids.clone());
143                    let info = pane_process_info(&ids.pane_id).await;
144                    let foreground_alive = match info {
145                        Ok(info) => {
146                            !(info.argv.as_deref().map(str::trim).unwrap_or("").is_empty()
147                                && info.pid.is_none())
148                        }
149                        Err(_) => true, // probe failure → assume alive
150                    };
151                    if foreground_alive {
152                        info!(workspace_id = %existing_id, label, "reattached to existing herdr workspace");
153                        self.start_observe();
154                        return Ok(());
155                    }
156                    // Dead CLI: the workspace shell is still there. `pane run`
157                    // the resume launch spec in the same pane so the next
158                    // inbound message lands on a fresh CLI.
159                    warn!(
160                        workspace_id = %existing_id,
161                        pane = %ids.pane_id,
162                        "herdr foreground CLI is dead; re-running launch spec in existing pane"
163                    );
164                    self.run_launch_spec_in_pane(bin, args).await?;
165                    self.start_observe();
166                    return Ok(());
167                }
168                Ok(None) => {
169                    // The label was listed but the workspace is already gone
170                    // (e.g. closed between list and get). Create fresh.
171                    warn!(
172                        workspace_id = %existing_id,
173                        label,
174                        "herdr workspace disappeared between list and get; creating new workspace"
175                    );
176                }
177                Err(err) => {
178                    // Unknown is not absent: probe failure must not destroy a
179                    // healthy workspace, but a hard error here is acceptable
180                    // because the worker will surface it via Ready-timeout.
181                    return Err(err.context(format!(
182                        "herdr workspace {existing_id} (label {label}) lookup failed"
183                    )));
184                }
185            }
186        }
187
188        let ids = workspace_create(&self.cwd, &label).await?;
189        self.set_ids(ids.clone());
190        self.run_launch_spec_in_pane(bin, args).await?;
191        self.start_observe();
192        Ok(())
193    }
194
195    /// Find the root pane id for a workspace via `herdr pane list`.
196    /// `workspace get` does not embed pane ids on herdr 0.8.x, so the reuse
197    /// path recovers the pane this way.
198    async fn pane_id_for_workspace(&self, workspace_id: &str) -> Result<Option<String>> {
199        let panes = pane_list().await?;
200        Ok(panes
201            .iter()
202            .find(|p| p.workspace_id == workspace_id)
203            .map(|p| p.pane_id.clone()))
204    }
205
206    /// Wait for the shell prompt (best-effort) then `pane run` the launch
207    /// spec in the current pane. A `wait-output` timeout still proceeds; the
208    /// spawn retry absorbs the residual race.
209    async fn run_launch_spec_in_pane(&self, bin: &str, args: &[String]) -> Result<()> {
210        let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
211        // The root pane is a shell; wait for a prompt before `pane run`.
212        // wait-output only lowers the race probability; a timeout still
213        // proceeds (spawn retry absorbs the residual race).
214        if let Ok(ready) = pane_wait_output(&pane_id, HERDR_SHELL_PROMPT_REGEX).await {
215            if ready {
216                info!(pane = %pane_id, "herdr shell ready before pane run");
217            } else {
218                warn!(
219                    pane = %pane_id,
220                    timeout_s = HERDR_SHELL_READY_TIMEOUT.as_secs(),
221                    "herdr shell prompt not matched; proceeding to pane run anyway"
222                );
223            }
224        }
225        let command = command_string(bin, args);
226        pane_run(&pane_id, &command).await?;
227        info!(pane = %pane_id, "herdr pane run issued");
228        Ok(())
229    }
230
231    async fn read_visible(&self) -> Result<String> {
232        let pane_id = self
233            .herdr_ids()
234            .context("herdr ids not set; cannot read pane")?
235            .pane_id;
236        Ok(pane_read_visible(&pane_id).await?.replace('\n', "\r\n"))
237    }
238}
239
240#[async_trait]
241impl SessionBackend for HerdrBackend {
242    async fn spawn(&self, bin: &str, args: &[String], opts: SpawnOpts) -> Result<()> {
243        let _ = opts;
244        self.managed_spawn(bin, args).await
245    }
246
247    async fn send_text(&self, text: &str) -> Result<()> {
248        let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
249        pane_send_text(&pane_id, text).await
250    }
251
252    async fn send_enter(&self) -> Result<()> {
253        let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
254        pane_send_keys(&pane_id, &["enter"]).await
255    }
256
257    async fn send_special_keys(&self, keys: &[String]) -> Result<()> {
258        let pane_id = self.herdr_ids().context("herdr ids not set")?.pane_id;
259        for key in keys {
260            match key.as_str() {
261                "Enter" => pane_send_keys(&pane_id, &["enter"]).await?,
262                "Down" => pane_send_keys(&pane_id, &["down"]).await?,
263                "Up" => pane_send_keys(&pane_id, &["up"]).await?,
264                "Left" => pane_send_keys(&pane_id, &["left"]).await?,
265                "Right" => pane_send_keys(&pane_id, &["right"]).await?,
266                "PageUp" => self.write_raw("\u{1b}[5~").await?,
267                "PageDown" => self.write_raw("\u{1b}[6~").await?,
268                "M-Enter" => self.write_raw("\u{1b}\r").await?,
269                "Tab" => pane_send_keys(&pane_id, &["tab"]).await?,
270                "Space" => pane_send_keys(&pane_id, &["space"]).await?,
271                "Escape" | "Esc" => pane_send_keys(&pane_id, &["esc"]).await?,
272                "C-c" => pane_send_keys(&pane_id, &["ctrl+c"]).await?,
273                other if other.chars().count() == 1 => self.write_raw(other).await?,
274                other => bail!("unsupported special key for herdr backend: {}", other),
275            }
276        }
277        Ok(())
278    }
279
280    async fn paste_text(&self, text: &str) -> Result<()> {
281        self.send_text(text).await
282    }
283
284    async fn write_raw(&self, text: &str) -> Result<()> {
285        self.send_text(text).await
286    }
287
288    async fn raw_input(&self, text: &str) -> Result<()> {
289        self.paste_text(text).await?;
290        tokio::time::sleep(RAW_INPUT_ENTER_DELAY).await;
291        self.send_enter().await
292    }
293
294    async fn capture_viewport(&self) -> Result<String> {
295        self.read_visible().await
296    }
297
298    async fn capture_current_screen(&self) -> Result<String> {
299        self.read_visible().await
300    }
301
302    async fn is_alive(&self) -> Result<bool> {
303        let Some(ids) = self.herdr_ids() else {
304            // No ids yet: probe failure / unknown is alive.
305            return Ok(true);
306        };
307        // Workspace confirmed missing → dead.
308        match workspace_get(&ids.workspace_id).await {
309            Ok(None) => return Ok(false),
310            Err(_) => return Ok(true), // unknown → alive
311            Ok(Some(_)) => {}
312        }
313        let info = match pane_process_info(&ids.pane_id).await {
314            Ok(info) => info,
315            Err(_) => return Ok(true), // probe failure → alive
316        };
317        let foreground_empty =
318            info.argv.as_deref().map(str::trim).unwrap_or("").is_empty() && info.pid.is_none();
319        Ok(!foreground_empty)
320    }
321
322    async fn child_pid(&self) -> Result<Option<u32>> {
323        let Some(ids) = self.herdr_ids() else {
324            return Ok(None);
325        };
326        Ok(pane_process_info(&ids.pane_id)
327            .await
328            .ok()
329            .and_then(|info| info.pid)
330            .and_then(|pid| u32::try_from(pid).ok()))
331    }
332
333    async fn kill(&self) -> Result<()> {
334        // Detach only: stop observe; the workspace and CLI stay running.
335        self.observe_stop.store(true, Ordering::SeqCst);
336        Ok(())
337    }
338
339    async fn destroy_session(&self) -> Result<()> {
340        // Only `/close` (and mux-tearing `/restart`) reach here for managed
341        // sessions. Force-close the labeled workspace.
342        if let Some(ids) = self.herdr_ids() {
343            workspace_close(&ids.workspace_id).await?;
344        }
345        Ok(())
346    }
347
348    async fn cursor_position(&self) -> Result<Option<(u16, u16)>> {
349        Ok(None)
350    }
351
352    fn subscribe(&self) -> broadcast::Receiver<String> {
353        self.data_tx.subscribe()
354    }
355}
356
357/// Adopt backend: observe + drive a user-owned Herdr pane. `spawn()` only
358/// starts observe; `destroy_session()` is a no-op (Beam never tears down a
359/// user's workspace), and it never `pane run`s a second CLI.
360#[derive(Debug)]
361pub struct HerdrObserveBackend {
362    workspace_id: String,
363    pane_id: String,
364    child_pid: Option<u32>,
365    data_tx: broadcast::Sender<String>,
366    observe_started: AtomicBool,
367    observe_stop: Arc<AtomicBool>,
368}
369
370impl HerdrObserveBackend {
371    pub fn new(workspace_id: String, pane_id: String, child_pid: Option<u32>) -> Self {
372        let (data_tx, _) = broadcast::channel(512);
373        Self {
374            workspace_id,
375            pane_id,
376            child_pid,
377            data_tx,
378            observe_started: AtomicBool::new(false),
379            observe_stop: Arc::new(AtomicBool::new(false)),
380        }
381    }
382
383    fn start_observe(&self) {
384        if self.observe_started.swap(true, Ordering::SeqCst) {
385            return;
386        }
387        let pane_id = self.pane_id.clone();
388        let tx = self.data_tx.clone();
389        let stop = self.observe_stop.clone();
390        tokio::spawn(async move {
391            observe::run_herdr_observe(
392                pane_id,
393                DEFAULT_TERMINAL_COLS,
394                DEFAULT_TERMINAL_ROWS,
395                tx,
396                stop,
397            )
398            .await;
399        });
400    }
401}
402
403#[async_trait]
404impl SessionBackend for HerdrObserveBackend {
405    async fn spawn(&self, _bin: &str, _args: &[String], _opts: SpawnOpts) -> Result<()> {
406        self.start_observe();
407        Ok(())
408    }
409
410    async fn send_text(&self, text: &str) -> Result<()> {
411        pane_send_text(&self.pane_id, text).await
412    }
413
414    async fn send_enter(&self) -> Result<()> {
415        pane_send_keys(&self.pane_id, &["enter"]).await
416    }
417
418    async fn send_special_keys(&self, keys: &[String]) -> Result<()> {
419        for key in keys {
420            match key.as_str() {
421                "Enter" => pane_send_keys(&self.pane_id, &["enter"]).await?,
422                "Down" => pane_send_keys(&self.pane_id, &["down"]).await?,
423                "Up" => pane_send_keys(&self.pane_id, &["up"]).await?,
424                "Left" => pane_send_keys(&self.pane_id, &["left"]).await?,
425                "Right" => pane_send_keys(&self.pane_id, &["right"]).await?,
426                "PageUp" => self.write_raw("\u{1b}[5~").await?,
427                "PageDown" => self.write_raw("\u{1b}[6~").await?,
428                "M-Enter" => self.write_raw("\u{1b}\r").await?,
429                "Tab" => pane_send_keys(&self.pane_id, &["tab"]).await?,
430                "Space" => pane_send_keys(&self.pane_id, &["space"]).await?,
431                "Escape" | "Esc" => pane_send_keys(&self.pane_id, &["esc"]).await?,
432                "C-c" => pane_send_keys(&self.pane_id, &["ctrl+c"]).await?,
433                other if other.chars().count() == 1 => self.write_raw(other).await?,
434                other => bail!("unsupported special key for herdr backend: {}", other),
435            }
436        }
437        Ok(())
438    }
439
440    async fn paste_text(&self, text: &str) -> Result<()> {
441        self.send_text(text).await
442    }
443
444    async fn write_raw(&self, text: &str) -> Result<()> {
445        self.send_text(text).await
446    }
447
448    async fn raw_input(&self, text: &str) -> Result<()> {
449        self.paste_text(text).await?;
450        tokio::time::sleep(RAW_INPUT_ENTER_DELAY).await;
451        self.send_enter().await
452    }
453
454    async fn capture_viewport(&self) -> Result<String> {
455        Ok(pane_read_visible(&self.pane_id)
456            .await?
457            .replace('\n', "\r\n"))
458    }
459
460    async fn capture_current_screen(&self) -> Result<String> {
461        self.capture_viewport().await
462    }
463
464    async fn is_alive(&self) -> Result<bool> {
465        // Adopt: unknown → alive; a confirmed-dead pane still keeps the
466        // session Active (next round observes only, never re-runs).
467        match workspace_get(&self.workspace_id).await {
468            Ok(None) => Ok(false),
469            Err(_) => Ok(true),
470            Ok(Some(_)) => {
471                let info = match pane_process_info(&self.pane_id).await {
472                    Ok(info) => info,
473                    Err(_) => return Ok(true),
474                };
475                let foreground_empty = info.argv.as_deref().map(str::trim).unwrap_or("").is_empty()
476                    && info.pid.is_none();
477                Ok(!foreground_empty)
478            }
479        }
480    }
481
482    async fn child_pid(&self) -> Result<Option<u32>> {
483        Ok(self.child_pid)
484    }
485
486    async fn kill(&self) -> Result<()> {
487        self.observe_stop.store(true, Ordering::SeqCst);
488        Ok(())
489    }
490
491    async fn destroy_session(&self) -> Result<()> {
492        // Never tear down a user-owned Herdr workspace/pane.
493        Ok(())
494    }
495
496    async fn cursor_position(&self) -> Result<Option<(u16, u16)>> {
497        Ok(None)
498    }
499
500    fn subscribe(&self) -> broadcast::Receiver<String> {
501        self.data_tx.subscribe()
502    }
503}
504
505#[cfg(test)]
506mod hermetic_tests {
507    use super::*;
508    use std::sync::OnceLock;
509
510    /// Serializes tests that mutate PATH so the fake herdr shim is found
511    /// first without racing other tests in the same process.
512    fn path_lock() -> &'static std::sync::Mutex<()> {
513        static PATH_TEST_LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
514        PATH_TEST_LOCK.get_or_init(|| std::sync::Mutex::new(()))
515    }
516
517    /// Point PATH at the fake herdr shim and return the state dir.
518    fn fake_herdr_env(_guard: &std::sync::MutexGuard<'_, ()>) -> std::path::PathBuf {
519        let shim_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support");
520        let state = std::env::temp_dir().join(format!("fake-herdr-{}", std::process::id()));
521        let _ = std::fs::remove_dir_all(&state);
522        std::fs::create_dir_all(&state).expect("state dir");
523        // Command::new("herdr") resolves by binary name; expose the shim
524        // under that name in a PATH-prepended bin dir.
525        let bin_dir = state.join("bin");
526        std::fs::create_dir_all(&bin_dir).expect("bin dir");
527        std::os::unix::fs::symlink(shim_dir.join("fake_herdr.sh"), bin_dir.join("herdr"))
528            .expect("herdr symlink");
529        let old_path = std::env::var_os("PATH").unwrap_or_default();
530        unsafe {
531            std::env::set_var(
532                "PATH",
533                format!("{}:{}", bin_dir.display(), old_path.to_string_lossy()),
534            );
535            std::env::set_var("FAKE_HERDR_STATE", &state);
536        }
537        state
538    }
539
540    fn restore_env(old_path: std::ffi::OsString, old_state: std::ffi::OsString) {
541        unsafe {
542            std::env::set_var("PATH", old_path);
543            std::env::set_var("FAKE_HERDR_STATE", old_state);
544        }
545    }
546
547    // Runs as one serialized test: every case mutates process PATH to point
548    // at the fake shim, which is unsafe to do concurrently with other tests.
549    #[test]
550    fn herdr_backend_managed_lifecycle_against_fake_shim() {
551        let guard = path_lock().lock().unwrap();
552        let old_path = std::env::var_os("PATH").unwrap_or_default();
553        let old_state = std::env::var_os("FAKE_HERDR_STATE").unwrap_or_default();
554        let state = fake_herdr_env(&guard);
555        let rt = tokio::runtime::Runtime::new().expect("runtime");
556        rt.block_on(async {
557            let opts = SpawnOpts {
558                cwd: "/repo".to_string(),
559                cols: 160,
560                rows: 50,
561                env: Vec::new(),
562            };
563
564            // Managed spawn: creates a labeled workspace and runs the quoted
565            // launch spec via `pane run`.
566            let backend = HerdrBackend::new("beam-deadbeef".to_string(), "/repo".to_string());
567            backend
568                .spawn(
569                    "/usr/bin/env",
570                    &["BEAM_SESSION_ID=s1".to_string(), "claude".to_string()],
571                    opts,
572                )
573                .await
574                .expect("managed spawn");
575            let ids = backend.herdr_ids().expect("ids after spawn");
576            assert_eq!(ids.workspace_id, "w1");
577            assert_eq!(ids.pane_id, "w1:p1");
578            let run_log = std::fs::read_to_string(state.join("input/run.log")).expect("run log");
579            assert!(run_log.contains("/usr/bin/env"));
580            assert!(run_log.contains("BEAM_SESSION_ID=s1"));
581            assert!(run_log.contains("claude"));
582
583            // is_alive: foreground pid present => alive.
584            assert!(backend.is_alive().await.expect("alive with foreground pid"));
585
586            // Input goes to the pane via send-text / send-keys.
587            backend.send_text("hi").await.expect("send text");
588            backend.send_enter().await.expect("send enter");
589            let text_log =
590                std::fs::read_to_string(state.join("input/send_text.log")).expect("text log");
591            assert_eq!(text_log.trim(), "hi");
592
593            // Empty foreground => dead.
594            std::fs::write(state.join("empty_foreground"), "").expect("toggle empty");
595            assert!(!backend.is_alive().await.expect("dead without foreground"));
596            std::fs::remove_file(state.join("empty_foreground")).expect("untoggle");
597
598            // destroy_session force-closes the workspace.
599            let wid = backend.herdr_ids().unwrap().workspace_id;
600            assert!(state.join("workspaces").join(&wid).exists());
601            backend.destroy_session().await.expect("destroy");
602            assert!(!state.join("workspaces").join(&wid).exists());
603
604            // Spawn again reuses the same labeled workspace (idempotent; the
605            // fake shim returns the existing id for the same label).
606            let backend2 = HerdrBackend::new("beam-deadbeef".to_string(), "/repo".to_string());
607            backend2
608                .spawn(
609                    "/usr/bin/env",
610                    &["claude".to_string()],
611                    SpawnOpts {
612                        cwd: "/repo".to_string(),
613                        cols: 160,
614                        rows: 50,
615                        env: Vec::new(),
616                    },
617                )
618                .await
619                .expect("re-spawn after destroy");
620            assert_eq!(backend2.herdr_ids().unwrap().workspace_id, "w1");
621
622            // Dead-CLI resume: spawn a fresh backend with the same label, then
623            // mark the foreground empty and re-spawn; the second spawn must
624            // `pane run` the launch spec again in the same pane (not create a
625            // second workspace and not just observe).
626            let run_log_before =
627                std::fs::read_to_string(state.join("input/run.log")).expect("run log before");
628            std::fs::write(state.join("empty_foreground"), "").expect("toggle empty");
629            let backend3 = HerdrBackend::new("beam-deadbeef".to_string(), "/repo".to_string());
630            backend3
631                .spawn(
632                    "/usr/bin/env",
633                    &["claude".to_string()],
634                    SpawnOpts {
635                        cwd: "/repo".to_string(),
636                        cols: 160,
637                        rows: 50,
638                        env: Vec::new(),
639                    },
640                )
641                .await
642                .expect("dead-cli resume spawn");
643            assert_eq!(backend3.herdr_ids().unwrap().workspace_id, "w1");
644            let run_log_after =
645                std::fs::read_to_string(state.join("input/run.log")).expect("run log after");
646            assert!(
647                run_log_after.lines().count() > run_log_before.lines().count(),
648                "dead CLI must re-run the launch spec"
649            );
650        });
651        restore_env(old_path, old_state);
652    }
653}