返回 CodeWhale
runtime.rs
根目录 / crates / lane / src / runtime.rs
1 //! Runtime backends: tmux durability, inline, vm/ci stubs (#4176).
2 //!
3 //! Runtime owns process/session lifecycle and stream-json log capture.
4 //! Fleet modules must not import this module.
5
6 use std::fs::{self, OpenOptions};
7 use std::io::{BufRead, BufReader, Write};
8 use std::path::{Path, PathBuf};
9 use std::process::{Command, ExitStatus, Stdio};
10 use std::thread;
11
12 use anyhow::{Context, Result, bail};
13 use serde::{Deserialize, Serialize};
14
15 use crate::registry::{LaneRecord, LaneRegistry, LaneStatus, TerminalTransition};
16 use crate::worktree::{WorktreeProvision, provision_worktree, remove_worktree_if_expired};
17
18 /// Execution backend for a lane.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20 #[serde(rename_all = "snake_case")]
21 pub enum RuntimeBackendKind {
22 Tmux,
23 Inline,
24 Vm,
25 Ci,
26 }
27
28 impl RuntimeBackendKind {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::Tmux => "tmux",
32 Self::Inline => "inline",
33 Self::Vm => "vm",
34 Self::Ci => "ci",
35 }
36 }
37
38 pub fn parse(raw: &str) -> Result<Self> {
39 match raw.trim().to_ascii_lowercase().as_str() {
40 "tmux" => Ok(Self::Tmux),
41 "inline" => Ok(Self::Inline),
42 "vm" => Ok(Self::Vm),
43 "ci" => Ok(Self::Ci),
44 other => bail!("unknown runtime backend `{other}` (use tmux|inline|vm|ci)"),
45 }
46 }
47 }
48
49 /// Inputs for starting a lane under a runtime backend.
50 #[derive(Clone)]
51 pub struct LaneStartSpec {
52 /// Command argv to run inside the backend (e.g. `codewhale exec …`).
53 pub command: Vec<String>,
54 /// Working directory for the command (defaults to worktree or cwd).
55 pub cwd: Option<PathBuf>,
56 /// Process-local runtime overrides. Values are never written into the
57 /// Lane record or command argv; tmux bridges them through a private 0600
58 /// environment file that the detached shell removes before execution.
59 pub environment: Vec<(String, String)>,
60 /// Executable that exposes Codewhale's hidden `lane-log-proxy` command.
61 /// Required by tmux so arbitrary/binary child output is framed as valid
62 /// NDJSON without trusting a shell pipeline.
63 pub log_proxy: Option<PathBuf>,
64 /// When set, provision an isolated git worktree + branch under this repo.
65 pub worktree: Option<WorktreeProvision>,
66 }
67
68 impl std::fmt::Debug for LaneStartSpec {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("LaneStartSpec")
71 .field("command", &self.command)
72 .field("cwd", &self.cwd)
73 .field(
74 "environment_keys",
75 &self
76 .environment
77 .iter()
78 .map(|(key, _)| key)
79 .collect::<Vec<_>>(),
80 )
81 .field("log_proxy", &self.log_proxy)
82 .field("worktree", &self.worktree)
83 .finish()
84 }
85 }
86
87 /// Runtime adapter contract.
88 pub trait RuntimeBackend {
89 fn kind(&self) -> RuntimeBackendKind;
90
91 /// Start the lane process/session; mutates record with attach/log metadata.
92 fn start(
93 &self,
94 registry: &LaneRegistry,
95 record: &mut LaneRecord,
96 spec: &LaneStartSpec,
97 ) -> Result<()>;
98
99 /// Human attach command, if any (tmux).
100 fn attach_command(&self, record: &LaneRecord) -> Option<String>;
101
102 /// Stop the running session/process.
103 ///
104 /// `fence` pins the durable lifecycle generation the caller observed. It is
105 /// evaluated under the registry's per-Lane lock, so a record that moved on
106 /// between the caller's read and this call is refused rather than stopped.
107 /// The return value distinguishes "this call stopped it" from "it was
108 /// already terminal" and "the fence did not match", so a caller can report
109 /// a truthful outcome instead of claiming a transition someone else made.
110 fn stop(
111 &self,
112 registry: &LaneRegistry,
113 record: &mut LaneRecord,
114 fence: Option<u64>,
115 ) -> Result<TerminalTransition>;
116
117 /// Reconcile durable backend state into the Lane record before display.
118 /// Most backends update synchronously and need no refresh; tmux records
119 /// the detached process exit in a private sidecar and folds it in on the
120 /// next read.
121 ///
122 /// Returns `true` when reconciliation *changed durable state*. Reads are
123 /// allowed to fold a finished Runtime exit into the record, but a surface
124 /// that calls this must be able to say so rather than reporting a pure
125 /// observation (#4022).
126 fn reconcile(&self, _registry: &LaneRegistry, _record: &mut LaneRecord) -> Result<bool> {
127 Ok(false)
128 }
129
130 /// Optional worktree TTL cleanup after stop.
131 fn cleanup_worktree(&self, record: &LaneRecord) -> Result<()> {
132 if let Some(path) = record.worktree_path.as_ref() {
133 remove_worktree_if_expired(
134 path,
135 record.worktree_ttl_secs,
136 record.stopped_at.as_deref(),
137 )?;
138 }
139 Ok(())
140 }
141 }
142
143 pub fn resolve_backend(kind: RuntimeBackendKind) -> Box<dyn RuntimeBackend> {
144 match kind {
145 RuntimeBackendKind::Tmux => Box::new(TmuxRuntime),
146 RuntimeBackendKind::Inline => Box::new(InlineRuntime),
147 RuntimeBackendKind::Vm => Box::new(StubRuntime {
148 kind: RuntimeBackendKind::Vm,
149 }),
150 RuntimeBackendKind::Ci => Box::new(StubRuntime {
151 kind: RuntimeBackendKind::Ci,
152 }),
153 }
154 }
155
156 pub fn backend_for(record: &LaneRecord) -> Box<dyn RuntimeBackend> {
157 resolve_backend(record.runtime)
158 }
159
160 fn append_log_event(log_path: &Path, event: serde_json::Value) -> Result<()> {
161 let mut file = OpenOptions::new()
162 .create(true)
163 .append(true)
164 .open(log_path)
165 .with_context(|| format!("open lane log {}", log_path.display()))?;
166 let mut encoded = serde_json::to_vec(&event).context("serialize lane log event")?;
167 encoded.push(b'\n');
168 file.write_all(&encoded)
169 .with_context(|| format!("write lane log {}", log_path.display()))?;
170 Ok(())
171 }
172
173 const MAX_EXIT_RECEIPT_BYTES: u64 = 4 * 1024;
174 const MAX_ENVIRONMENT_BYTES: u64 = 1024 * 1024;
175 const MAX_CHILD_LOG_FRAME_BYTES: usize = 64 * 1024;
176 const LANE_PROXY_FAILURE_EXIT_CODE: i32 = 125;
177
178 #[derive(Debug, Deserialize, Serialize)]
179 struct LaneExitReceipt {
180 lane_id: String,
181 exit_code: i32,
182 }
183
184 /// Inputs to the hidden Rust log proxy used by detached runtimes.
185 ///
186 /// The proxy is deliberately exposed by the Lane crate so the thin CLI
187 /// facade can invoke it before loading user configuration. Secrets travel in
188 /// the private environment file, never in argv or the Lane record.
189 #[derive(Debug, Clone)]
190 pub struct LaneLogProxySpec {
191 pub command: Vec<String>,
192 pub log_path: PathBuf,
193 pub receipt_path: PathBuf,
194 pub receipt_tmp_path: PathBuf,
195 pub environment_path: Option<PathBuf>,
196 pub lane_id: String,
197 }
198
199 fn lane_exit_receipt_path(log_path: &Path) -> PathBuf {
200 log_path.with_extension("exit.json")
201 }
202
203 fn lane_exit_receipt_tmp_path(log_path: &Path) -> PathBuf {
204 log_path.with_extension("exit.json.tmp")
205 }
206
207 fn lane_environment_path(log_path: &Path) -> PathBuf {
208 log_path.with_extension("env.json")
209 }
210
211 fn lane_environment_tmp_path(path: &Path) -> PathBuf {
212 path.with_extension("json.tmp")
213 }
214
215 fn remove_file_if_present(path: &Path) -> Result<()> {
216 match std::fs::remove_file(path) {
217 Ok(()) => Ok(()),
218 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
219 Err(err) => Err(err).with_context(|| format!("remove {}", path.display())),
220 }
221 }
222
223 fn valid_environment_key(key: &str) -> bool {
224 let mut chars = key.chars();
225 chars
226 .next()
227 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
228 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
229 }
230
231 fn write_lane_environment(path: &Path, environment: &[(String, String)]) -> Result<()> {
232 for (key, _) in environment {
233 if !valid_environment_key(key) {
234 bail!("invalid lane environment key {key:?}");
235 }
236 }
237 let encoded = serde_json::to_vec(environment).context("serialize private lane environment")?;
238 if encoded.len() as u64 > MAX_ENVIRONMENT_BYTES {
239 bail!(
240 "private lane environment exceeds {} bytes",
241 MAX_ENVIRONMENT_BYTES
242 );
243 }
244
245 let tmp_path = lane_environment_tmp_path(path);
246 remove_file_if_present(path)?;
247 remove_file_if_present(&tmp_path)?;
248 let mut options = OpenOptions::new();
249 options.create_new(true).write(true);
250 #[cfg(unix)]
251 {
252 use std::os::unix::fs::OpenOptionsExt;
253 options.mode(0o600);
254 }
255 let result = (|| {
256 let mut file = options
257 .open(&tmp_path)
258 .with_context(|| format!("create private lane environment {}", tmp_path.display()))?;
259 file.write_all(&encoded)
260 .with_context(|| format!("write private lane environment {}", tmp_path.display()))?;
261 file.sync_all()
262 .with_context(|| format!("sync private lane environment {}", tmp_path.display()))?;
263 fs::rename(&tmp_path, path)
264 .with_context(|| format!("publish private lane environment {}", path.display()))?;
265 Ok(())
266 })();
267 if result.is_err() {
268 let _ = remove_file_if_present(&tmp_path);
269 let _ = remove_file_if_present(path);
270 }
271 result
272 }
273
274 fn append_child_output(log_path: &Path, stream: &str, bytes: &[u8]) -> Result<()> {
275 let mut line = bytes;
276 if let Some(stripped) = line.strip_suffix(b"\n") {
277 line = stripped;
278 }
279 if let Some(stripped) = line.strip_suffix(b"\r") {
280 line = stripped;
281 }
282 if line.is_empty() {
283 return Ok(());
284 }
285 if let Ok(event) = serde_json::from_slice::<serde_json::Value>(line) {
286 append_log_event(log_path, event)
287 } else {
288 append_log_event(
289 log_path,
290 serde_json::json!({
291 "type": "lane_log",
292 "stream": stream,
293 "message": String::from_utf8_lossy(line),
294 }),
295 )
296 }
297 }
298
299 fn stream_child_output(
300 reader: impl std::io::Read,
301 log_path: PathBuf,
302 stream: &'static str,
303 ) -> Result<()> {
304 let mut reader = BufReader::new(reader);
305 let mut line = Vec::new();
306 loop {
307 line.clear();
308 let mut reached_eof = false;
309 while line.len() < MAX_CHILD_LOG_FRAME_BYTES {
310 let buffer = reader
311 .fill_buf()
312 .with_context(|| format!("read lane child {stream}"))?;
313 if buffer.is_empty() {
314 reached_eof = true;
315 break;
316 }
317 let available = buffer
318 .iter()
319 .position(|byte| *byte == b'\n')
320 .map_or(buffer.len(), |position| position + 1);
321 let take = available.min(MAX_CHILD_LOG_FRAME_BYTES - line.len());
322 let ended_line = buffer.get(take.saturating_sub(1)) == Some(&b'\n');
323 line.extend_from_slice(&buffer[..take]);
324 reader.consume(take);
325 if ended_line {
326 break;
327 }
328 }
329 if line.is_empty() && reached_eof {
330 return Ok(());
331 }
332 append_child_output(&log_path, stream, &line)?;
333 if reached_eof {
334 return Ok(());
335 }
336 }
337 }
338
339 fn read_lane_environment(path: &Path) -> Result<Vec<(String, String)>> {
340 let metadata = fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
341 if metadata.len() > MAX_ENVIRONMENT_BYTES {
342 bail!(
343 "private lane environment {} exceeds {} bytes",
344 path.display(),
345 MAX_ENVIRONMENT_BYTES
346 );
347 }
348 let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
349 if bytes.len() as u64 > MAX_ENVIRONMENT_BYTES {
350 bail!(
351 "private lane environment {} exceeds {} bytes",
352 path.display(),
353 MAX_ENVIRONMENT_BYTES
354 );
355 }
356 let environment: Vec<(String, String)> =
357 serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
358 for (key, _) in &environment {
359 if !valid_environment_key(key) {
360 bail!("invalid lane environment key {key:?}");
361 }
362 }
363 Ok(environment)
364 }
365
366 fn write_lane_exit_receipt(
367 receipt_path: &Path,
368 receipt_tmp_path: &Path,
369 lane_id: &str,
370 exit_code: i32,
371 ) -> Result<()> {
372 let encoded = serde_json::to_vec(&LaneExitReceipt {
373 lane_id: lane_id.to_string(),
374 exit_code,
375 })
376 .context("serialize lane exit receipt")?;
377 if encoded.len() as u64 > MAX_EXIT_RECEIPT_BYTES {
378 bail!("serialized lane exit receipt exceeds size bound");
379 }
380 remove_file_if_present(receipt_tmp_path)?;
381 let mut options = OpenOptions::new();
382 options.create_new(true).write(true);
383 #[cfg(unix)]
384 {
385 use std::os::unix::fs::OpenOptionsExt;
386 options.mode(0o600);
387 }
388 let result = (|| {
389 let mut file = options
390 .open(receipt_tmp_path)
391 .with_context(|| format!("create {}", receipt_tmp_path.display()))?;
392 file.write_all(&encoded)
393 .with_context(|| format!("write {}", receipt_tmp_path.display()))?;
394 file.sync_all()
395 .with_context(|| format!("sync {}", receipt_tmp_path.display()))?;
396 fs::rename(receipt_tmp_path, receipt_path)
397 .with_context(|| format!("publish {}", receipt_path.display()))?;
398 Ok(())
399 })();
400 if result.is_err() {
401 let _ = remove_file_if_present(receipt_tmp_path);
402 }
403 result
404 }
405
406 fn exit_status_code(status: ExitStatus) -> i32 {
407 if let Some(code) = status.code() {
408 return code;
409 }
410 #[cfg(unix)]
411 {
412 use std::os::unix::process::ExitStatusExt;
413 if let Some(signal) = status.signal() {
414 return 128 + signal;
415 }
416 }
417 LANE_PROXY_FAILURE_EXIT_CODE
418 }
419
420 fn append_proxy_failure(log_path: &Path, lane_id: &str, error: &anyhow::Error) -> Result<()> {
421 append_log_event(
422 log_path,
423 serde_json::json!({
424 "type": "lane_proxy_error",
425 "lane_id": lane_id,
426 "error": format!("{error:#}"),
427 }),
428 )
429 }
430
431 /// Run a child command while framing all output as NDJSON and atomically
432 /// publishing a private exit receipt. Returns the process-style exit code the
433 /// hidden CLI should propagate to tmux.
434 pub fn run_lane_log_proxy(spec: LaneLogProxySpec) -> Result<i32> {
435 let LaneLogProxySpec {
436 command,
437 log_path,
438 receipt_path,
439 receipt_tmp_path,
440 environment_path,
441 lane_id,
442 } = spec;
443
444 let fail = |error: anyhow::Error, exit_code: i32| -> Result<i32> {
445 append_proxy_failure(&log_path, &lane_id, &error)?;
446 write_lane_exit_receipt(&receipt_path, &receipt_tmp_path, &lane_id, exit_code)?;
447 Ok(exit_code)
448 };
449
450 if command.is_empty() {
451 return fail(anyhow::anyhow!("lane log proxy requires a command"), 127);
452 }
453
454 let environment = if let Some(path) = environment_path.as_deref() {
455 let loaded = read_lane_environment(path);
456 let removed = remove_file_if_present(path);
457 match (loaded, removed) {
458 (Ok(environment), Ok(())) => environment,
459 (Err(error), Ok(())) => return fail(error, LANE_PROXY_FAILURE_EXIT_CODE),
460 (Ok(_), Err(error)) | (Err(_), Err(error)) => return Err(error),
461 }
462 } else {
463 Vec::new()
464 };
465
466 let mut child_command = Command::new(&command[0]);
467 child_command.args(&command[1..]);
468 child_command.envs(environment);
469 child_command.stdout(Stdio::piped()).stderr(Stdio::piped());
470 let mut child = match child_command.spawn() {
471 Ok(child) => child,
472 Err(error) => {
473 return fail(
474 anyhow::Error::from(error).context(format!("spawn lane child command {command:?}")),
475 127,
476 );
477 }
478 };
479 let stdout = match child.stdout.take() {
480 Some(stdout) => stdout,
481 None => return fail(anyhow::anyhow!("lane child stdout was not piped"), 125),
482 };
483 let stderr = match child.stderr.take() {
484 Some(stderr) => stderr,
485 None => return fail(anyhow::anyhow!("lane child stderr was not piped"), 125),
486 };
487 let stdout_log = log_path.clone();
488 let stderr_log = log_path.clone();
489 let stdout_thread = thread::spawn(move || stream_child_output(stdout, stdout_log, "stdout"));
490 let stderr_thread = thread::spawn(move || stream_child_output(stderr, stderr_log, "stderr"));
491
492 let status = match child.wait() {
493 Ok(status) => status,
494 Err(error) => {
495 return fail(
496 anyhow::Error::from(error).context(format!("wait for lane child {command:?}")),
497 LANE_PROXY_FAILURE_EXIT_CODE,
498 );
499 }
500 };
501 let stdout_result = stdout_thread
502 .join()
503 .map_err(|_| anyhow::anyhow!("lane proxy stdout logger panicked"))?;
504 let stderr_result = stderr_thread
505 .join()
506 .map_err(|_| anyhow::anyhow!("lane proxy stderr logger panicked"))?;
507 let mut exit_code = exit_status_code(status);
508 if let Some(error) = stdout_result.err().or_else(|| stderr_result.err()) {
509 append_proxy_failure(&log_path, &lane_id, &error)?;
510 exit_code = LANE_PROXY_FAILURE_EXIT_CODE;
511 }
512 write_lane_exit_receipt(&receipt_path, &receipt_tmp_path, &lane_id, exit_code)?;
513 Ok(exit_code)
514 }
515
516 fn read_lane_exit_receipt(log_path: &Path, lane_id: &str) -> Result<Option<LaneExitReceipt>> {
517 let path = lane_exit_receipt_path(log_path);
518 let metadata = match std::fs::metadata(&path) {
519 Ok(metadata) => metadata,
520 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
521 Err(err) => return Err(err).with_context(|| format!("stat {}", path.display())),
522 };
523 if metadata.len() > MAX_EXIT_RECEIPT_BYTES {
524 bail!(
525 "lane exit receipt {} exceeds {} bytes",
526 path.display(),
527 MAX_EXIT_RECEIPT_BYTES
528 );
529 }
530 let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
531 let receipt: LaneExitReceipt =
532 serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
533 if receipt.lane_id != lane_id {
534 bail!(
535 "lane exit receipt {} belongs to {}, expected {}",
536 path.display(),
537 receipt.lane_id,
538 lane_id
539 );
540 }
541 Ok(Some(receipt))
542 }
543
544 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
545 enum TmuxSessionState {
546 Present,
547 Absent,
548 }
549
550 fn tmux_command(socket: &Path) -> Command {
551 let mut command = Command::new("tmux");
552 command.arg("-S").arg(socket);
553 command
554 }
555
556 fn ensure_tmux_available() -> Result<()> {
557 let output = Command::new("tmux")
558 .arg("-V")
559 .output()
560 .context("tmux runtime requires the `tmux` executable")?;
561 if !output.status.success() {
562 bail!(
563 "tmux runtime is unavailable: `tmux -V` failed with {}: {}",
564 output.status,
565 String::from_utf8_lossy(&output.stderr).trim()
566 );
567 }
568 Ok(())
569 }
570
571 fn tmux_session_state(socket: &Path, session: &str) -> Result<TmuxSessionState> {
572 let output = tmux_command(socket)
573 .args(["has-session", "-t", session])
574 .stdout(Stdio::null())
575 .stderr(Stdio::piped())
576 .output()
577 .with_context(|| format!("query tmux session {session}"))?;
578 if output.status.success() {
579 return Ok(TmuxSessionState::Present);
580 }
581 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
582 if stderr.contains("can't find session:")
583 || stderr.contains("no server running on")
584 || (stderr.contains("error connecting to") && stderr.contains("no such file or directory"))
585 {
586 return Ok(TmuxSessionState::Absent);
587 }
588 bail!(
589 "tmux has-session for {session} failed with {}: {}",
590 output.status,
591 stderr.trim()
592 )
593 }
594
595 fn stop_tmux_session(socket: &Path, session: &str) -> Result<()> {
596 let status = tmux_command(socket)
597 .args(["kill-session", "-t", session])
598 .stdout(Stdio::null())
599 .stderr(Stdio::null())
600 .status()
601 .with_context(|| format!("kill tmux session {session}"))?;
602 match tmux_session_state(socket, session).with_context(|| {
603 format!(
604 "confirm tmux session {session} on {} stopped after kill-session ({status})",
605 socket.display()
606 )
607 })? {
608 TmuxSessionState::Absent => {}
609 TmuxSessionState::Present => {
610 bail!("tmux session {session} remains active after kill-session ({status})")
611 }
612 }
613 // A nonzero kill can be benign when the process and session exited in the
614 // same instant. The explicit absence check above is the source of truth.
615 Ok(())
616 }
617
618 fn tmux_log_proxy_command(
619 proxy: &Path,
620 command: &[String],
621 log_path: &Path,
622 receipt_path: &Path,
623 receipt_tmp_path: &Path,
624 environment_path: Option<&Path>,
625 lane_id: &str,
626 ) -> String {
627 let mut argv = vec![
628 proxy.display().to_string(),
629 "lane-log-proxy".to_string(),
630 "--log-path".to_string(),
631 log_path.display().to_string(),
632 "--receipt-path".to_string(),
633 receipt_path.display().to_string(),
634 "--receipt-tmp-path".to_string(),
635 receipt_tmp_path.display().to_string(),
636 "--lane-id".to_string(),
637 lane_id.to_string(),
638 ];
639 if let Some(path) = environment_path {
640 argv.push("--environment-path".to_string());
641 argv.push(path.display().to_string());
642 }
643 argv.push("--".to_string());
644 argv.extend(command.iter().cloned());
645 format!("exec {}", shell_join(&argv))
646 }
647
648 fn apply_worktree(record: &mut LaneRecord, spec: &LaneStartSpec) -> Result<Option<PathBuf>> {
649 let Some(wt) = spec.worktree.as_ref() else {
650 return Ok(spec.cwd.clone());
651 };
652 let provisioned = provision_worktree(wt)?;
653 record.worktree_path = Some(provisioned.path.clone());
654 record.branch = Some(provisioned.branch.clone());
655 Ok(Some(provisioned.path))
656 }
657
658 /// Durable local tmux sessions + attach + stream-json log file.
659 #[derive(Debug, Default)]
660 pub struct TmuxRuntime;
661
662 impl RuntimeBackend for TmuxRuntime {
663 fn kind(&self) -> RuntimeBackendKind {
664 RuntimeBackendKind::Tmux
665 }
666
667 fn start(
668 &self,
669 registry: &LaneRegistry,
670 record: &mut LaneRecord,
671 spec: &LaneStartSpec,
672 ) -> Result<()> {
673 if spec.command.is_empty() {
674 bail!("tmux runtime requires a non-empty command");
675 }
676 // Dry-run is an explicit test hook only. A missing/broken tmux binary
677 // must fail closed rather than persisting a fictional Running Lane.
678 let dry_run = std::env::var_os("CODEWHALE_LANE_TMUX_DRY_RUN").is_some();
679 if !dry_run && let Err(error) = ensure_tmux_available() {
680 append_log_event(
681 &record.log_path,
682 serde_json::json!({
683 "type": "lane_failed",
684 "lane_id": record.id,
685 "runtime": "tmux",
686 "error": error.to_string(),
687 }),
688 )?;
689 let _ = registry.mark_terminal_if_active(record, LaneStatus::Failed)?;
690 return Err(error);
691 }
692
693 let cwd = apply_worktree(record, spec)?;
694 let session = format!("cw-{}", record.id);
695 let socket = registry.root().join("tmux.sock");
696 record.tmux_session = Some(session.clone());
697 record.tmux_socket = Some(socket.clone());
698 record.attach_target = Some(format!(
699 "tmux -S {} attach -t {}",
700 shell_escape(&socket.display().to_string()),
701 shell_escape(&session)
702 ));
703
704 append_log_event(
705 &record.log_path,
706 serde_json::json!({
707 "type": "lane_started",
708 "lane_id": record.id,
709 "runtime": "tmux",
710 "session": session,
711 "workflow": record.workflow,
712 "fleet": record.fleet,
713 "issue": record.issue,
714 "dry_run": dry_run,
715 }),
716 )?;
717
718 if dry_run {
719 append_log_event(
720 &record.log_path,
721 serde_json::json!({
722 "type": "lane_log",
723 "message": "tmux dry-run: session recorded without spawning process",
724 "command": spec.command,
725 "cwd": cwd.as_ref().map(|p| p.display().to_string()),
726 }),
727 )?;
728 if !registry.mark_running_if_pending(record)? {
729 bail!(
730 "lane `{}` was stopped before tmux dry-run start completed",
731 record.id
732 );
733 }
734 return Ok(());
735 }
736
737 let log_proxy = spec
738 .log_proxy
739 .as_deref()
740 .context("tmux runtime requires a lane log proxy executable")?;
741
742 // Detached session: child output remains an operator journal only.
743 // Terminal control state goes through a separate bounded, atomically
744 // renamed receipt so child stdout cannot forge Lane completion.
745 let receipt_path = lane_exit_receipt_path(&record.log_path);
746 let receipt_tmp_path = lane_exit_receipt_tmp_path(&record.log_path);
747 let environment_path = lane_environment_path(&record.log_path);
748 remove_file_if_present(&receipt_path)?;
749 remove_file_if_present(&receipt_tmp_path)?;
750 remove_file_if_present(&environment_path)?;
751 let environment_path = if spec.environment.is_empty() {
752 None
753 } else {
754 write_lane_environment(&environment_path, &spec.environment)?;
755 Some(environment_path)
756 };
757 let shell_cmd = tmux_log_proxy_command(
758 log_proxy,
759 &spec.command,
760 &record.log_path,
761 &receipt_path,
762 &receipt_tmp_path,
763 environment_path.as_deref(),
764 &record.id,
765 );
766
767 let mut cmd = tmux_command(&socket);
768 cmd.args(["new-session", "-d", "-s", &session]);
769 if let Some(cwd) = cwd.as_ref() {
770 cmd.arg("-c").arg(cwd);
771 }
772 cmd.arg(shell_cmd);
773 let proposed_record = record.clone();
774 let spawned = std::cell::Cell::new(false);
775 let rolled_back = std::cell::Cell::new(false);
776 match registry.mark_running_if_pending_with(
777 record,
778 || {
779 let status = cmd
780 .status()
781 .with_context(|| format!("spawn tmux session {session}"))?;
782 if !status.success() {
783 bail!("tmux new-session failed with {status}");
784 }
785 spawned.set(true);
786 Ok(())
787 },
788 || {
789 stop_tmux_session(&socket, &session)?;
790 rolled_back.set(true);
791 Ok(())
792 },
793 ) {
794 Ok(true) => {}
795 Ok(false) => {
796 if let Some(path) = environment_path.as_deref() {
797 remove_file_if_present(path)?;
798 }
799 let mut stopped_record = proposed_record;
800 stopped_record.stopped_at = record.stopped_at.clone();
801 self.cleanup_worktree(&stopped_record)?;
802 bail!("lane `{}` was stopped before tmux launch", record.id);
803 }
804 Err(error) => {
805 if let Some(path) = environment_path.as_deref() {
806 let _ = remove_file_if_present(path);
807 }
808 if !spawned.get() || rolled_back.get() {
809 let _ = registry.mark_terminal_if_active(record, LaneStatus::Failed)?;
810 let mut failed_record = proposed_record;
811 failed_record.stopped_at = record.stopped_at.clone();
812 self.cleanup_worktree(&failed_record)?;
813 }
814 return Err(error);
815 }
816 }
817 Ok(())
818 }
819
820 fn attach_command(&self, record: &LaneRecord) -> Option<String> {
821 if record.status != LaneStatus::Running {
822 return None;
823 }
824 let socket = record.tmux_socket.as_ref()?;
825 let session = record.tmux_session.as_deref()?;
826 Some(format!(
827 "tmux -S {} attach -t {}",
828 shell_escape(&socket.display().to_string()),
829 shell_escape(session)
830 ))
831 }
832
833 fn stop(
834 &self,
835 registry: &LaneRegistry,
836 record: &mut LaneRecord,
837 fence: Option<u64>,
838 ) -> Result<TerminalTransition> {
839 let dry_run = std::env::var_os("CODEWHALE_LANE_TMUX_DRY_RUN").is_some();
840 let transition = registry.mark_terminal_if_active_fenced(
841 record,
842 LaneStatus::Stopped,
843 fence,
844 |current| {
845 if !dry_run {
846 match (
847 current.tmux_socket.as_deref(),
848 current.tmux_session.as_deref(),
849 ) {
850 (Some(socket), Some(session)) => stop_tmux_session(socket, session)?,
851 _ if current.status == LaneStatus::Running => {
852 bail!(
853 "running tmux lane `{}` has incomplete pinned session metadata; refusing unsafe stop",
854 current.id
855 );
856 }
857 _ => {}
858 }
859 }
860 Ok(())
861 },
862 )?;
863 if transition.transitioned() {
864 append_log_event(
865 &record.log_path,
866 serde_json::json!({
867 "type": "lane_stopped",
868 "lane_id": record.id,
869 "session": record.tmux_session,
870 }),
871 )?;
872 remove_file_if_present(&lane_environment_path(&record.log_path))?;
873 self.cleanup_worktree(record)?;
874 }
875 Ok(transition)
876 }
877
878 fn reconcile(&self, registry: &LaneRegistry, record: &mut LaneRecord) -> Result<bool> {
879 // Pending is the pre-launch state. Never reconcile it: a concurrent
880 // status read must not race a fast `tmux new-session` and overwrite
881 // the start path's later Running transition.
882 if record.status != LaneStatus::Running {
883 return Ok(false);
884 }
885
886 let receipt = read_lane_exit_receipt(&record.log_path, &record.id)?;
887 let (lane_status, exit_code, reason) = if let Some(receipt) = receipt {
888 (
889 if receipt.exit_code == 0 {
890 LaneStatus::Completed
891 } else {
892 LaneStatus::Failed
893 },
894 Some(receipt.exit_code),
895 "process_exit_receipt",
896 )
897 } else {
898 if std::env::var_os("CODEWHALE_LANE_TMUX_DRY_RUN").is_some() {
899 return Ok(false);
900 }
901 let (Some(socket), Some(session)) = (
902 record.tmux_socket.as_deref(),
903 record.tmux_session.as_deref(),
904 ) else {
905 bail!(
906 "running tmux lane `{}` lacks pinned socket/session metadata; refusing unsafe reconciliation",
907 record.id
908 );
909 };
910 match tmux_session_state(socket, session)? {
911 TmuxSessionState::Present => return Ok(false),
912 TmuxSessionState::Absent => {}
913 }
914 (
915 LaneStatus::Failed,
916 None,
917 "tmux_session_missing_without_exit_receipt",
918 )
919 };
920
921 if registry.mark_terminal_if_active(record, lane_status)? {
922 append_log_event(
923 &record.log_path,
924 serde_json::json!({
925 "type": "lane_reconciled",
926 "lane_id": record.id,
927 "exit_code": exit_code,
928 "status": lane_status.as_str(),
929 "reason": reason,
930 }),
931 )?;
932 remove_file_if_present(&lane_environment_path(&record.log_path))?;
933 self.cleanup_worktree(record)?;
934 return Ok(true);
935 }
936 Ok(false)
937 }
938 }
939
940 /// In-process / local command runtime (no tmux). Used for tests and headless.
941 #[derive(Debug, Default)]
942 pub struct InlineRuntime;
943
944 impl RuntimeBackend for InlineRuntime {
945 fn kind(&self) -> RuntimeBackendKind {
946 RuntimeBackendKind::Inline
947 }
948
949 fn start(
950 &self,
951 registry: &LaneRegistry,
952 record: &mut LaneRecord,
953 spec: &LaneStartSpec,
954 ) -> Result<()> {
955 if spec.command.is_empty() {
956 bail!("inline runtime requires a non-empty command");
957 }
958 let cwd = apply_worktree(record, spec)?;
959 append_log_event(
960 &record.log_path,
961 serde_json::json!({
962 "type": "lane_started",
963 "lane_id": record.id,
964 "runtime": "inline",
965 "command": spec.command,
966 }),
967 )?;
968 if !registry.mark_running_if_pending(record)? {
969 bail!(
970 "lane `{}` was stopped before inline start completed",
971 record.id
972 );
973 }
974
975 let mut cmd = Command::new(&spec.command[0]);
976 if spec.command.len() > 1 {
977 cmd.args(&spec.command[1..]);
978 }
979 if let Some(cwd) = cwd.as_ref() {
980 cmd.current_dir(cwd);
981 }
982 cmd.envs(spec.environment.iter().map(|(key, value)| (key, value)));
983 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
984 let mut child = match cmd.spawn() {
985 Ok(child) => child,
986 Err(err) => {
987 append_log_event(
988 &record.log_path,
989 serde_json::json!({
990 "type": "lane_failed",
991 "lane_id": record.id,
992 "error": err.to_string(),
993 }),
994 )?;
995 let _ = registry.mark_terminal_if_active(record, LaneStatus::Failed)?;
996 return Err(err).with_context(|| format!("run inline command {:?}", spec.command));
997 }
998 };
999 let stdout = child
1000 .stdout
1001 .take()
1002 .context("inline lane child stdout was not piped")?;
1003 let stderr = child
1004 .stderr
1005 .take()
1006 .context("inline lane child stderr was not piped")?;
1007 let stdout_log = record.log_path.clone();
1008 let stderr_log = record.log_path.clone();
1009 let stdout_thread =
1010 thread::spawn(move || stream_child_output(stdout, stdout_log, "stdout"));
1011 let stderr_thread =
1012 thread::spawn(move || stream_child_output(stderr, stderr_log, "stderr"));
1013 let status = child
1014 .wait()
1015 .with_context(|| format!("wait for inline command {:?}", spec.command))?;
1016 let stdout_result = stdout_thread
1017 .join()
1018 .map_err(|_| anyhow::anyhow!("inline stdout logger panicked"))?;
1019 let stderr_result = stderr_thread
1020 .join()
1021 .map_err(|_| anyhow::anyhow!("inline stderr logger panicked"))?;
1022 let logging_error = stdout_result.err().or_else(|| stderr_result.err());
1023
1024 if status.success() && logging_error.is_none() {
1025 append_log_event(
1026 &record.log_path,
1027 serde_json::json!({"type": "lane_completed", "lane_id": record.id}),
1028 )?;
1029 let _ = registry.mark_terminal_if_active(record, LaneStatus::Completed)?;
1030 } else {
1031 append_log_event(
1032 &record.log_path,
1033 serde_json::json!({
1034 "type": "lane_failed",
1035 "lane_id": record.id,
1036 "status": format!("{status}"),
1037 "logging_error": logging_error.as_ref().map(ToString::to_string),
1038 }),
1039 )?;
1040 let _ = registry.mark_terminal_if_active(record, LaneStatus::Failed)?;
1041 }
1042 if let Some(err) = logging_error {
1043 return Err(err).context("stream inline lane output");
1044 }
1045 Ok(())
1046 }
1047
1048 fn attach_command(&self, _record: &LaneRecord) -> Option<String> {
1049 None
1050 }
1051
1052 fn stop(
1053 &self,
1054 registry: &LaneRegistry,
1055 record: &mut LaneRecord,
1056 fence: Option<u64>,
1057 ) -> Result<TerminalTransition> {
1058 let transition = registry.mark_terminal_if_active_fenced(
1059 record,
1060 LaneStatus::Stopped,
1061 fence,
1062 |current| {
1063 if current.status == LaneStatus::Running {
1064 bail!(
1065 "inline lane `{}` cannot be stopped safely from another process",
1066 current.id
1067 );
1068 }
1069 Ok(())
1070 },
1071 )?;
1072 if transition.transitioned() {
1073 self.cleanup_worktree(record)?;
1074 }
1075 Ok(transition)
1076 }
1077 }
1078
1079 /// Placeholder for remote VM / CI backends (surface only in Phase 1).
1080 #[derive(Debug)]
1081 struct StubRuntime {
1082 kind: RuntimeBackendKind,
1083 }
1084
1085 impl RuntimeBackend for StubRuntime {
1086 fn kind(&self) -> RuntimeBackendKind {
1087 self.kind
1088 }
1089
1090 fn start(
1091 &self,
1092 registry: &LaneRegistry,
1093 record: &mut LaneRecord,
1094 _spec: &LaneStartSpec,
1095 ) -> Result<()> {
1096 let error = format!(
1097 "{} runtime is not implemented; use tmux or inline",
1098 self.kind.as_str()
1099 );
1100 append_log_event(
1101 &record.log_path,
1102 serde_json::json!({
1103 "type": "lane_failed",
1104 "lane_id": record.id,
1105 "runtime": self.kind.as_str(),
1106 "error": &error,
1107 }),
1108 )?;
1109 let _ = registry.mark_terminal_if_active(record, LaneStatus::Failed)?;
1110 bail!("{error}")
1111 }
1112
1113 fn attach_command(&self, _record: &LaneRecord) -> Option<String> {
1114 None
1115 }
1116
1117 fn stop(
1118 &self,
1119 registry: &LaneRegistry,
1120 record: &mut LaneRecord,
1121 fence: Option<u64>,
1122 ) -> Result<TerminalTransition> {
1123 registry.mark_terminal_if_active_fenced(record, LaneStatus::Stopped, fence, |_| Ok(()))
1124 }
1125 }
1126
1127 fn shell_escape(s: &str) -> String {
1128 format!("'{}'", s.replace('\'', "'\\''"))
1129 }
1130
1131 fn shell_join(args: &[String]) -> String {
1132 args.iter()
1133 .map(|a| shell_escape(a))
1134 .collect::<Vec<_>>()
1135 .join(" ")
1136 }
1137
1138 #[cfg(test)]
1139 mod tests {
1140 use super::*;
1141 use std::ffi::OsString;
1142 use std::sync::{Mutex, MutexGuard, OnceLock};
1143 use tempfile::tempdir;
1144
1145 fn tmux_env_lock() -> MutexGuard<'static, ()> {
1146 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1147 LOCK.get_or_init(|| Mutex::new(()))
1148 .lock()
1149 .unwrap_or_else(|poisoned| poisoned.into_inner())
1150 }
1151
1152 struct ScopedEnvVar {
1153 name: &'static str,
1154 previous: Option<OsString>,
1155 }
1156
1157 impl ScopedEnvVar {
1158 fn set(name: &'static str, value: &std::ffi::OsStr) -> Self {
1159 let previous = std::env::var_os(name);
1160 // SAFETY: tmux environment tests hold `tmux_env_lock` and restore
1161 // the process environment in Drop.
1162 unsafe { std::env::set_var(name, value) };
1163 Self { name, previous }
1164 }
1165
1166 #[cfg(unix)]
1167 fn remove(name: &'static str) -> Self {
1168 let previous = std::env::var_os(name);
1169 // SAFETY: tmux environment tests hold `tmux_env_lock` and restore
1170 // the process environment in Drop.
1171 unsafe { std::env::remove_var(name) };
1172 Self { name, previous }
1173 }
1174 }
1175
1176 impl Drop for ScopedEnvVar {
1177 fn drop(&mut self) {
1178 // SAFETY: paired with the serialized mutation above.
1179 unsafe {
1180 if let Some(previous) = self.previous.take() {
1181 std::env::set_var(self.name, previous);
1182 } else {
1183 std::env::remove_var(self.name);
1184 }
1185 }
1186 }
1187 }
1188
1189 #[test]
1190 fn tmux_dry_run_start_attach_stop_roundtrip() {
1191 let _env_guard = tmux_env_lock();
1192 let _dry_run = ScopedEnvVar::set("CODEWHALE_LANE_TMUX_DRY_RUN", std::ffi::OsStr::new("1"));
1193 let dir = tempdir().unwrap();
1194 let reg = LaneRegistry::open(dir.path()).unwrap();
1195 let mut record = reg
1196 .create_pending(
1197 Some("stopship".into()),
1198 Some("stopship".into()),
1199 Some("4375".into()),
1200 None,
1201 RuntimeBackendKind::Tmux,
1202 None,
1203 )
1204 .unwrap();
1205 let backend = TmuxRuntime;
1206 backend
1207 .start(
1208 &reg,
1209 &mut record,
1210 &LaneStartSpec {
1211 command: vec!["echo".into(), "hello-lane".into()],
1212 cwd: None,
1213 environment: Vec::new(),
1214 log_proxy: None,
1215 worktree: None,
1216 },
1217 )
1218 .unwrap();
1219 assert_eq!(record.status, LaneStatus::Running);
1220 assert!(record.tmux_session.is_some());
1221 let expected_socket = reg.root().join("tmux.sock");
1222 assert_eq!(
1223 record.tmux_socket.as_deref(),
1224 Some(expected_socket.as_path())
1225 );
1226 let attach = backend.attach_command(&record).expect("attach");
1227 assert!(attach.contains("tmux -S"));
1228 assert!(attach.contains(&expected_socket.display().to_string()));
1229 assert!(attach.contains("attach -t"));
1230 let log = std::fs::read_to_string(&record.log_path).unwrap();
1231 assert!(log.contains("lane_started"));
1232
1233 backend.stop(&reg, &mut record, None).unwrap();
1234 assert_eq!(record.status, LaneStatus::Stopped);
1235 let reloaded = reg.load(&record.id).unwrap();
1236 assert_eq!(reloaded.status, LaneStatus::Stopped);
1237 }
1238
1239 #[cfg(unix)]
1240 #[test]
1241 fn unavailable_tmux_fails_lane_instead_of_faking_running() {
1242 use std::os::unix::fs::symlink;
1243
1244 let _env_guard = tmux_env_lock();
1245 let dir = tempdir().unwrap();
1246 let bin_dir = dir.path().join("bin");
1247 fs::create_dir(&bin_dir).unwrap();
1248 symlink("/usr/bin/false", bin_dir.join("tmux")).unwrap();
1249 let prior_path = std::env::var_os("PATH").unwrap_or_default();
1250 let combined_path = std::env::join_paths(
1251 std::iter::once(bin_dir).chain(std::env::split_paths(&prior_path)),
1252 )
1253 .unwrap();
1254 let _path = ScopedEnvVar::set("PATH", &combined_path);
1255 let _dry_run = ScopedEnvVar::remove("CODEWHALE_LANE_TMUX_DRY_RUN");
1256
1257 let reg = LaneRegistry::open(dir.path().join("registry")).unwrap();
1258 let mut record = reg
1259 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
1260 .unwrap();
1261 let error = TmuxRuntime
1262 .start(
1263 &reg,
1264 &mut record,
1265 &LaneStartSpec {
1266 command: vec!["/bin/true".to_string()],
1267 cwd: None,
1268 environment: Vec::new(),
1269 log_proxy: Some(PathBuf::from("/bin/true")),
1270 worktree: None,
1271 },
1272 )
1273 .unwrap_err();
1274 assert!(error.to_string().contains("tmux runtime is unavailable"));
1275 assert_eq!(record.status, LaneStatus::Failed);
1276 assert_eq!(reg.load(&record.id).unwrap().status, LaneStatus::Failed);
1277 assert!(
1278 String::from_utf8_lossy(&fs::read(&record.log_path).unwrap()).contains("lane_failed")
1279 );
1280 }
1281
1282 #[test]
1283 fn unimplemented_remote_runtimes_fail_terminally() {
1284 for kind in [RuntimeBackendKind::Vm, RuntimeBackendKind::Ci] {
1285 let dir = tempdir().unwrap();
1286 let reg = LaneRegistry::open(dir.path()).unwrap();
1287 let mut record = reg
1288 .create_pending(None, None, None, None, kind, None)
1289 .unwrap();
1290 let error = resolve_backend(kind)
1291 .start(
1292 &reg,
1293 &mut record,
1294 &LaneStartSpec {
1295 command: vec!["/bin/true".to_string()],
1296 cwd: None,
1297 environment: Vec::new(),
1298 log_proxy: None,
1299 worktree: None,
1300 },
1301 )
1302 .unwrap_err();
1303 assert!(error.to_string().contains("runtime is not implemented"));
1304 assert_eq!(record.status, LaneStatus::Failed);
1305 assert_eq!(reg.load(&record.id).unwrap().status, LaneStatus::Failed);
1306 }
1307 }
1308
1309 #[test]
1310 fn inline_runtime_writes_stream_json_log() {
1311 let dir = tempdir().unwrap();
1312 let reg = LaneRegistry::open(dir.path()).unwrap();
1313 let mut record = reg
1314 .create_pending(
1315 Some("demo".into()),
1316 None,
1317 None,
1318 Some("echo".into()),
1319 RuntimeBackendKind::Inline,
1320 None,
1321 )
1322 .unwrap();
1323 InlineRuntime
1324 .start(
1325 &reg,
1326 &mut record,
1327 &LaneStartSpec {
1328 command: vec!["echo".into(), "inline-ok".into()],
1329 cwd: None,
1330 environment: Vec::new(),
1331 log_proxy: None,
1332 worktree: None,
1333 },
1334 )
1335 .unwrap();
1336 assert_eq!(record.status, LaneStatus::Completed);
1337 let log = std::fs::read_to_string(&record.log_path).unwrap();
1338 assert!(log.contains("inline-ok"), "log={log}");
1339 assert!(log.contains("lane_completed"));
1340 }
1341
1342 #[test]
1343 fn lane_start_spec_debug_redacts_environment_values() {
1344 let spec = LaneStartSpec {
1345 command: vec!["codewhale-tui".into()],
1346 cwd: None,
1347 environment: vec![("DEEPSEEK_API_KEY".into(), "secret-value".into())],
1348 log_proxy: None,
1349 worktree: None,
1350 };
1351 let rendered = format!("{spec:?}");
1352 assert!(rendered.contains("DEEPSEEK_API_KEY"));
1353 assert!(!rendered.contains("secret-value"));
1354 }
1355
1356 #[cfg(unix)]
1357 #[test]
1358 fn inline_runtime_persists_typed_receipts_before_process_exit() {
1359 let dir = tempdir().unwrap();
1360 let reg = LaneRegistry::open(dir.path()).unwrap();
1361 let record = reg
1362 .create_pending(
1363 Some("demo".into()),
1364 None,
1365 None,
1366 None,
1367 RuntimeBackendKind::Inline,
1368 None,
1369 )
1370 .unwrap();
1371 let log_path = record.log_path.clone();
1372 let (done_tx, done_rx) = std::sync::mpsc::channel();
1373 let handle = thread::spawn(move || {
1374 let mut record = record;
1375 let result = InlineRuntime.start(
1376 &reg,
1377 &mut record,
1378 &LaneStartSpec {
1379 command: vec![
1380 "sh".into(),
1381 "-c".into(),
1382 "printf '%s\\n' '{\"type\":\"workflow_event\",\"run_id\":\"workflow_live\",\"event\":{\"type\":\"run_started\"}}'; sleep 0.25; printf done"
1383 .into(),
1384 ],
1385 cwd: None,
1386 environment: Vec::new(),
1387 log_proxy: None,
1388 worktree: None,
1389 },
1390 );
1391 let _ = done_tx.send(result);
1392 });
1393
1394 let mut observed_live = false;
1395 for _ in 0..50 {
1396 let log = std::fs::read_to_string(&log_path).unwrap_or_default();
1397 if log.contains("workflow_live") {
1398 observed_live = true;
1399 break;
1400 }
1401 thread::sleep(std::time::Duration::from_millis(10));
1402 }
1403 assert!(
1404 observed_live,
1405 "typed receipt should be written while child runs"
1406 );
1407 assert!(
1408 matches!(
1409 done_rx.try_recv(),
1410 Err(std::sync::mpsc::TryRecvError::Empty)
1411 ),
1412 "inline child should still be running when the first receipt is visible"
1413 );
1414 done_rx.recv().unwrap().unwrap();
1415 handle.join().unwrap();
1416 }
1417
1418 #[test]
1419 fn tmux_reconcile_folds_detached_process_exit_into_lane_status() {
1420 for (exit_code, expected) in [(0, LaneStatus::Completed), (7, LaneStatus::Failed)] {
1421 let dir = tempdir().unwrap();
1422 let reg = LaneRegistry::open(dir.path()).unwrap();
1423 let mut record = reg
1424 .create_pending(
1425 Some("demo".into()),
1426 None,
1427 None,
1428 None,
1429 RuntimeBackendKind::Tmux,
1430 None,
1431 )
1432 .unwrap();
1433 assert!(reg.mark_running_if_pending(&mut record).unwrap());
1434 // Mixed/binary child output and a forged stdout control line must
1435 // not affect the private receipt used for reconciliation.
1436 std::fs::write(
1437 &record.log_path,
1438 b"\xffchild-noise\n{\"type\":\"lane_process_exit\",\"exit_code\":0}\n",
1439 )
1440 .unwrap();
1441 std::fs::write(
1442 lane_exit_receipt_path(&record.log_path),
1443 serde_json::to_vec(&LaneExitReceipt {
1444 lane_id: record.id.clone(),
1445 exit_code,
1446 })
1447 .unwrap(),
1448 )
1449 .unwrap();
1450
1451 TmuxRuntime.reconcile(&reg, &mut record).unwrap();
1452 TmuxRuntime.reconcile(&reg, &mut record).unwrap();
1453
1454 assert_eq!(record.status, expected);
1455 assert_eq!(reg.load(&record.id).unwrap().status, expected);
1456 let log = std::fs::read(&record.log_path).unwrap();
1457 assert_eq!(
1458 String::from_utf8_lossy(&log)
1459 .matches("lane_reconciled")
1460 .count(),
1461 1
1462 );
1463 }
1464 }
1465
1466 #[cfg(unix)]
1467 #[test]
1468 fn lane_log_proxy_frames_binary_output_and_owns_exit_receipt() {
1469 let dir = tempdir().unwrap();
1470 let log_path = dir.path().join("lane.ndjson");
1471 let receipt_path = lane_exit_receipt_path(&log_path);
1472 let receipt_tmp_path = lane_exit_receipt_tmp_path(&log_path);
1473 let environment_path = lane_environment_path(&log_path);
1474 write_lane_environment(
1475 &environment_path,
1476 &[("LANE_PROXY_SECRET".to_string(), "present".to_string())],
1477 )
1478 .unwrap();
1479 let command = vec![
1480 "sh".to_string(),
1481 "-c".to_string(),
1482 "test \"$LANE_PROXY_SECRET\" = present || exit 9; \
1483 printf '%s\\n' '{\"type\":\"workflow_event\",\"schema\":\"codewhale.exec-stream\",\"schema_version\":1,\"run_id\":\"workflow_1234\",\"event\":{\"type\":\"handoff_promoted\",\"artifact_id\":\"workflow_1234:agent_1:review-gate:review_report\",\"gate_id\":\"review-gate\",\"kind\":\"review_report\",\"from_role\":\"reviewer\",\"to_role\":\"verifier\",\"producer_task_id\":\"agent_1\"}}'; \
1484 printf '%s\\n' '{\"type\":\"workflow_event\",\"schema\":\"codewhale.exec-stream\",\"schema_version\":1,\"run_id\":\"workflow_1234\",\"event\":{\"type\":\"handoff_consumed\",\"artifact_id\":\"workflow_1234:agent_1:review-gate:review_report\",\"kind\":\"review_report\",\"from_role\":\"reviewer\",\"to_role\":\"verifier\",\"consumer_task_id\":\"agent_2\"}}'; \
1485 printf 'unterminated\\377'; \
1486 printf '%s\\n' '{\"type\":\"lane_process_exit\",\"exit_code\":0}' >&2; \
1487 exit 7"
1488 .to_string(),
1489 ];
1490 let exit_code = run_lane_log_proxy(LaneLogProxySpec {
1491 command,
1492 log_path: log_path.clone(),
1493 receipt_path: receipt_path.clone(),
1494 receipt_tmp_path,
1495 environment_path: Some(environment_path.clone()),
1496 lane_id: "lane-proof".to_string(),
1497 })
1498 .unwrap();
1499
1500 assert_eq!(exit_code, 7);
1501 assert!(!environment_path.exists());
1502 let log = std::fs::read(&log_path).unwrap();
1503 let lines = log
1504 .split(|byte| *byte == b'\n')
1505 .filter(|line| !line.is_empty())
1506 .collect::<Vec<_>>();
1507 assert!(lines.len() >= 3, "log={}", String::from_utf8_lossy(&log));
1508 let values = lines
1509 .iter()
1510 .map(|line| {
1511 serde_json::from_slice::<serde_json::Value>(line)
1512 .unwrap_or_else(|error| panic!("invalid NDJSON {line:?}: {error}"))
1513 })
1514 .collect::<Vec<_>>();
1515 let workflow_event = values
1516 .iter()
1517 .find(|value| value["type"] == "workflow_event")
1518 .expect("preserved workflow handoff receipt");
1519 assert_eq!(workflow_event["schema"], "codewhale.exec-stream");
1520 assert_eq!(workflow_event["schema_version"], 1);
1521 assert_eq!(workflow_event["run_id"], "workflow_1234");
1522 assert_eq!(workflow_event["event"]["type"], "handoff_promoted");
1523 assert_eq!(
1524 workflow_event["event"]["artifact_id"],
1525 "workflow_1234:agent_1:review-gate:review_report"
1526 );
1527 assert_eq!(workflow_event["event"]["gate_id"], "review-gate");
1528 assert_eq!(workflow_event["event"]["kind"], "review_report");
1529 assert_eq!(workflow_event["event"]["from_role"], "reviewer");
1530 assert_eq!(workflow_event["event"]["to_role"], "verifier");
1531 assert_eq!(workflow_event["event"]["producer_task_id"], "agent_1");
1532 assert!(workflow_event["event"].get("payload").is_none());
1533 let consumed_event = values
1534 .iter()
1535 .find(|value| value["event"]["type"] == "handoff_consumed")
1536 .expect("preserved workflow handoff consumption receipt");
1537 assert_eq!(consumed_event["schema"], "codewhale.exec-stream");
1538 assert_eq!(consumed_event["schema_version"], 1);
1539 assert_eq!(consumed_event["run_id"], "workflow_1234");
1540 assert_eq!(
1541 consumed_event["event"]["artifact_id"],
1542 workflow_event["event"]["artifact_id"]
1543 );
1544 assert_eq!(consumed_event["event"]["kind"], "review_report");
1545 assert_eq!(consumed_event["event"]["from_role"], "reviewer");
1546 assert_eq!(consumed_event["event"]["to_role"], "verifier");
1547 assert_eq!(consumed_event["event"]["consumer_task_id"], "agent_2");
1548 assert!(consumed_event["event"].get("payload").is_none());
1549 let rendered = String::from_utf8_lossy(&log);
1550 assert!(rendered.contains("workflow_event"));
1551 assert!(rendered.contains("lane_process_exit"));
1552 assert!(rendered.contains("lane_log"));
1553 let receipt = read_lane_exit_receipt(&log_path, "lane-proof")
1554 .unwrap()
1555 .expect("private receipt");
1556 assert_eq!(receipt.exit_code, 7);
1557 }
1558
1559 #[test]
1560 fn invalid_environment_key_never_leaves_a_partial_secret_file() {
1561 let dir = tempdir().unwrap();
1562 let path = dir.path().join("lane.env.json");
1563 let result = write_lane_environment(
1564 &path,
1565 &[
1566 ("VALID_KEY".to_string(), "first-secret".to_string()),
1567 ("INVALID-KEY".to_string(), "second-secret".to_string()),
1568 ],
1569 );
1570 assert!(result.is_err());
1571 assert!(!path.exists());
1572 assert!(!lane_environment_tmp_path(&path).exists());
1573 }
1574
1575 #[cfg(unix)]
1576 #[test]
1577 fn private_environment_is_mode_0600_and_malformed_input_is_removed() {
1578 use std::os::unix::fs::PermissionsExt;
1579
1580 let dir = tempdir().unwrap();
1581 let path = dir.path().join("lane.env.json");
1582 write_lane_environment(&path, &[("SECRET".to_string(), "sensitive".to_string())]).unwrap();
1583 assert_eq!(
1584 fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1585 0o600
1586 );
1587
1588 fs::write(&path, b"{malformed").unwrap();
1589 let log_path = dir.path().join("lane.ndjson");
1590 let exit_code = run_lane_log_proxy(LaneLogProxySpec {
1591 command: vec!["/bin/true".to_string()],
1592 receipt_path: lane_exit_receipt_path(&log_path),
1593 receipt_tmp_path: lane_exit_receipt_tmp_path(&log_path),
1594 environment_path: Some(path.clone()),
1595 lane_id: "lane-malformed-env".to_string(),
1596 log_path: log_path.clone(),
1597 })
1598 .unwrap();
1599 assert_eq!(exit_code, LANE_PROXY_FAILURE_EXIT_CODE);
1600 assert!(!path.exists());
1601 assert!(String::from_utf8_lossy(&fs::read(log_path).unwrap()).contains("lane_proxy_error"));
1602 }
1603
1604 #[cfg(unix)]
1605 #[test]
1606 fn tmux_stop_failure_keeps_lane_running_and_preserves_cleanup_targets() {
1607 use std::os::unix::fs::symlink;
1608
1609 let _env_guard = tmux_env_lock();
1610 let dir = tempdir().unwrap();
1611 let bin_dir = dir.path().join("bin");
1612 fs::create_dir(&bin_dir).unwrap();
1613 symlink("/usr/bin/false", bin_dir.join("tmux")).unwrap();
1614 let prior_path = std::env::var_os("PATH").unwrap_or_default();
1615 let combined_path = std::env::join_paths(
1616 std::iter::once(bin_dir.clone()).chain(std::env::split_paths(&prior_path)),
1617 )
1618 .unwrap();
1619 let _path = ScopedEnvVar::set("PATH", &combined_path);
1620 let _dry_run = ScopedEnvVar::remove("CODEWHALE_LANE_TMUX_DRY_RUN");
1621
1622 let reg = LaneRegistry::open(dir.path().join("registry")).unwrap();
1623 let mut record = reg
1624 .create_pending(
1625 Some("demo".into()),
1626 None,
1627 None,
1628 None,
1629 RuntimeBackendKind::Tmux,
1630 Some(0),
1631 )
1632 .unwrap();
1633 record.tmux_session = Some(format!("cw-{}", record.id));
1634 record.tmux_socket = Some(reg.root().join("tmux.sock"));
1635 let worktree = dir.path().join("worktree");
1636 fs::create_dir(&worktree).unwrap();
1637 record.worktree_path = Some(worktree.clone());
1638 let environment_path = lane_environment_path(&record.log_path);
1639 write_lane_environment(
1640 &environment_path,
1641 &[("SECRET".to_string(), "still-private".to_string())],
1642 )
1643 .unwrap();
1644 assert!(reg.mark_running_if_pending(&mut record).unwrap());
1645
1646 let error = TmuxRuntime.stop(&reg, &mut record, None).unwrap_err();
1647 assert!(format!("{error:#}").contains("tmux has-session"));
1648 assert_eq!(record.status, LaneStatus::Running);
1649 assert_eq!(reg.load(&record.id).unwrap().status, LaneStatus::Running);
1650 assert!(environment_path.exists());
1651 assert!(worktree.exists());
1652 }
1653
1654 #[cfg(unix)]
1655 #[test]
1656 fn tmux_reconcile_marks_vanished_session_failed_without_receipt() {
1657 use std::os::unix::fs::PermissionsExt;
1658
1659 let _env_guard = tmux_env_lock();
1660 let dir = tempdir().unwrap();
1661 let bin_dir = dir.path().join("bin");
1662 fs::create_dir(&bin_dir).unwrap();
1663 let tmux = bin_dir.join("tmux");
1664 fs::write(
1665 &tmux,
1666 "#!/bin/sh\nprintf '%s\\n' 'no server running on /tmp/codewhale-test.sock' >&2\nexit 1\n",
1667 )
1668 .unwrap();
1669 let mut permissions = fs::metadata(&tmux).unwrap().permissions();
1670 permissions.set_mode(0o755);
1671 fs::set_permissions(&tmux, permissions).unwrap();
1672 let prior_path = std::env::var_os("PATH").unwrap_or_default();
1673 let combined_path = std::env::join_paths(
1674 std::iter::once(bin_dir).chain(std::env::split_paths(&prior_path)),
1675 )
1676 .unwrap();
1677 let _path = ScopedEnvVar::set("PATH", &combined_path);
1678 let _dry_run = ScopedEnvVar::remove("CODEWHALE_LANE_TMUX_DRY_RUN");
1679
1680 let reg = LaneRegistry::open(dir.path()).unwrap();
1681 let mut record = reg
1682 .create_pending(
1683 Some("demo".into()),
1684 None,
1685 None,
1686 None,
1687 RuntimeBackendKind::Tmux,
1688 None,
1689 )
1690 .unwrap();
1691 record.tmux_session = Some(format!("missing-{}", record.id));
1692 record.tmux_socket = Some(reg.root().join("tmux.sock"));
1693 assert!(reg.mark_running_if_pending(&mut record).unwrap());
1694
1695 TmuxRuntime.reconcile(&reg, &mut record).unwrap();
1696
1697 assert_eq!(record.status, LaneStatus::Failed);
1698 let log = std::fs::read_to_string(&record.log_path).unwrap();
1699 assert!(log.contains("tmux_session_missing_without_exit_receipt"));
1700 }
1701 }
1702
1702 lines RUST