From 90ad7a12de0b9f15c612bf5949510622cfaaace4 Mon Sep 17 00:00:00 2001 From: pbean Date: Mon, 13 Apr 2026 14:07:23 -0700 Subject: [PATCH 1/6] Fix tmux shell compatibility runtime --- .../data/tmux-commands.md | 22 +- source/src/story_automator/adapters/tmux.py | 242 +--- source/src/story_automator/commands/tmux.py | 437 +----- .../src/story_automator/core/tmux_runtime.py | 1188 +++++++++++++++++ source/tests/test_tmux_runtime.py | 165 +++ 5 files changed, 1415 insertions(+), 639 deletions(-) create mode 100644 source/src/story_automator/core/tmux_runtime.py create mode 100644 source/tests/test_tmux_runtime.py diff --git a/payload/.claude/skills/bmad-story-automator/data/tmux-commands.md b/payload/.claude/skills/bmad-story-automator/data/tmux-commands.md index 5c2a7eb..57bce9a 100644 --- a/payload/.claude/skills/bmad-story-automator/data/tmux-commands.md +++ b/payload/.claude/skills/bmad-story-automator/data/tmux-commands.md @@ -112,6 +112,12 @@ The status check script automatically detects Claude vs Codex sessions: - **Codex completion cues:** `tokens used` line, shell prompt return (e.g., `❯`, `$`, `#`), or clean tmux exit - Codex sessions get 1.5x longer wait estimates (90s vs 60s default); "succeeded" alone is not treated as active +**Runtime Behavior (v1.13.0):** +- Normal `tmux-wrapper spawn` now uses a runner-based tmux path with explicit session state, not `tmux send-keys` +- Lifecycle truth comes from the session state file first; pane capture is still used for exported `output_file` artifacts +- Sessions keep dead panes with `remain-on-exit on`, so `pane_dead` and `pane_dead_status` remain inspectable after completion +- Temporary migration switch: `SA_TMUX_RUNTIME=legacy|runner|auto` (`auto` is the default) + **For full output (when completed/stuck):** ```bash script="$(printf "%s" "{project_root}/.claude/skills/bmad-story-automator/scripts/story-automator")" @@ -150,14 +156,20 @@ This environment variable tells the stop hook to allow the session to complete n Without it, the stop hook will block child sessions from stopping, causing infinite loops. ```bash -# CRITICAL: Always use -x 200 -y 50 for wide terminal (prevents line-wrap issues with long commands) -tmux new-session -d -s "SESSION_NAME" -x 200 -y 50 -c "PROJECT_PATH" -e STORY_AUTOMATOR_CHILD=true -tmux send-keys -t "SESSION_NAME" "COMMAND_HERE" Enter +# Current implementation: +# 1. create the session with an inert placeholder command +# 2. set remain-on-exit on the pane/session +# 3. respawn the pane into a bash runner that executes the per-session command file +tmux new-session -d -s "SESSION_NAME" -x 200 -y 50 -c "PROJECT_PATH" \ + -e STORY_AUTOMATOR_CHILD=true -e AI_AGENT=codex -e CLAUDECODE= -e BASH_ENV= \ + /bin/sleep 86400 +tmux set-option -t "PANE_ID" remain-on-exit on +tmux respawn-pane -k -t "PANE_ID" /usr/bin/bash "/tmp/.sa--session-SESSION_NAME-runner.sh" ``` -**Terminal Dimensions:** The `-x 200 -y 50` flags create a wider terminal window. This is **REQUIRED** for commands longer than 80 characters (e.g., YOLO mode retrospective prompts ~1500 chars). Without this, line-wrapping causes shell parsing failures and silent command execution failures. +**Terminal Dimensions:** The `-x 200 -y 50` flags remain required. They preserve the wide pane geometry used for interactive agent sessions and pane-derived transcripts. -**Long Command Script Files:** Commands exceeding 500 characters are written to `/tmp/sa-cmd-{session}.sh` and executed via `bash /tmp/sa-cmd-{session}.sh`. The `bash` prefix is critical — without it, the shell receives a raw path and silently fails. These script files are not auto-cleaned; they persist in `/tmp/` until system cleanup. +**Command Files:** The runtime now always writes a per-session command file and a per-session runner file. This removes the old short-command vs long-command split and avoids quoting or line-wrap failures from `send-keys`. Explicit `tmux-wrapper kill` deletes these artifacts; stale terminal artifacts are garbage-collected after the retention TTL. See `data/tmux-long-command-debugging.md` for detailed troubleshooting. diff --git a/source/src/story_automator/adapters/tmux.py b/source/src/story_automator/adapters/tmux.py index 695041e..b18dedc 100644 --- a/source/src/story_automator/adapters/tmux.py +++ b/source/src/story_automator/adapters/tmux.py @@ -1,26 +1,29 @@ from __future__ import annotations -import json -import os -import re -import time from dataclasses import dataclass from pathlib import Path -from ..core.common import ( - clamp_int, - command_exists, - ensure_dir, - file_exists, - filter_input_box, - iso_now, - md5_hex8, - project_root, - read_text, - run_cmd, - safe_int, - trim_lines, - write_atomic, +from ..core.common import ensure_dir, run_cmd, trim_lines +from ..core.tmux_runtime import ( + agent_cli, + agent_type, + detect_codex_session, + estimate_wait, + extract_active_task, + generate_session_name, + heartbeat_check, + load_session_state, + pane_status, + project_hash, + project_slug, + save_session_state, + session_status, + tmux_display, + tmux_has_session, + tmux_kill_session, + tmux_list_sessions as _tmux_list_sessions, + tmux_show_environment, + verify_or_create_output, ) @@ -34,56 +37,10 @@ class TmuxStatus: session_state: str -def project_slug() -> str: - base = project_root().name.lower() - slug = "".join(ch for ch in base if ch.isalnum())[:8] - return slug or "project" - - -def project_hash(root: str | Path | None = None) -> str: - resolved = Path(root) if root else project_root() - return md5_hex8(str(Path(resolved).resolve())) - - -def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") -> str: - stamp = time.strftime("%y%m%d-%H%M%S", time.localtime()) - suffix = story_id.replace(".", "-") - name = f"sa-{project_slug()}-{stamp}-e{epic}-s{suffix}-{step}" - if cycle: - name += f"-r{cycle}" - return name - - -def agent_type() -> str: - return os.environ.get("AI_AGENT", "claude") - - -def agent_cli(agent: str) -> str: - return "codex exec" if agent == "codex" else "claude --dangerously-skip-permissions" - - def skill_prefix(agent: str) -> str: return "none" if agent == "codex" else "bmad-" -def tmux_has_session(session: str) -> bool: - _, code = run_cmd("tmux", "has-session", "-t", session) - return code == 0 - - -def tmux_display(session: str, fmt: str) -> str: - output, _ = run_cmd("tmux", "display-message", "-t", session, "-p", fmt) - return output.strip() - - -def tmux_show_environment(session: str, key: str) -> str: - output, code = run_cmd("tmux", "show-environment", "-t", session, key) - if code != 0: - return "" - parts = output.strip().split("=", 1) - return parts[1] if len(parts) == 2 else "" - - def tmux_new_session(session: str, root: str | Path, selected_agent: str) -> tuple[str, int]: return run_cmd( "tmux", @@ -114,101 +71,17 @@ def tmux_send_keys(session: str, command: str, enter: bool = True) -> tuple[str, def tmux_list_sessions(project_only: bool = False) -> list[str]: - if not command_exists("tmux"): - return [] - output, code = run_cmd("tmux", "list-sessions", "-F", "#{session_name}") - if code != 0: - return [] - sessions = [line for line in trim_lines(output) if line.startswith("sa-")] - if project_only: - prefix = f"sa-{project_slug()}-" - sessions = [line for line in sessions if line.startswith(prefix)] + sessions, _ = _tmux_list_sessions(project_only) return sessions -def tmux_kill_session(session: str) -> None: - run_cmd("tmux", "kill-session", "-t", session) - hash_value = project_hash() - for file_name in ( - f"/tmp/.sa-{hash_value}-session-{session}-state.json", - f"/tmp/sa-{hash_value}-output-{session}.txt", - f"/tmp/sa-cmd-{session}.sh", - ): - Path(file_name).unlink(missing_ok=True) - - -def pane_status(session: str) -> str: - pane_dead = tmux_display(session, "#{pane_dead}") - exit_status = tmux_display(session, "#{pane_dead_status}") - if pane_dead == "1": - if exit_status and exit_status != "0": - return f"crashed:{exit_status}" - return "exited:0" - return "alive" - - -def detect_codex_session(session: str, capture: str) -> str: - if tmux_show_environment(session, "AI_AGENT") == "codex": - return "codex" - if re.search(r"(?i)OpenAI Codex|codex exec|gpt-[0-9]+-codex|tokens used|codex-cli", capture): - return "codex" - return "claude" - - -def estimate_wait(task: str, done: int, total: int) -> int: - lower = task.lower() - if re.search(r"loading|reading|searching|parsing", lower): - return 30 - if re.search(r"presenting|waiting|menu|select|choose", lower): - return 15 - if re.search(r"running tests|testing|building|compiling|installing", lower): - return 120 - if re.search(r"writing|editing|updating|creating|fixing", lower): - return 60 - if total > 0: - progress = 100 * done // total - if progress < 25: - return 90 - if progress < 50: - return 75 - if progress < 75: - return 60 - return 30 - return 60 - - -def extract_active_task(capture: str) -> str: - pattern = re.compile(r"(?i)(·|✳|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|✶|✻|Galloping|Working|Running|Beaming|Razzmatazzing|Creating)") - active = "" - for line in trim_lines(capture): - if pattern.search(line): - active = line - if not active: - return "" - active = re.sub(r"[·✳⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✶✻]", "", active) - active = re.sub(r"\(ctrl\+c.*", "", active).strip() - return active[:80] - - -def parse_statusline_time(capture: str) -> str: - pattern = re.compile(r"\| [0-9]{2}:[0-9]{2}:[0-9]{2}") - last = "" - for line in trim_lines(capture): - matches = pattern.findall(line) - if matches: - last = matches[-1].replace("|", "").strip() - return last - - def load_json_state(path: str | Path) -> dict[str, object]: - if not file_exists(path): - return {} - return json.loads(read_text(path)) + return load_session_state(path) def save_json_state(path: str | Path, payload: dict[str, object]) -> None: ensure_dir(Path(path).parent) - write_atomic(path, json.dumps(payload)) + save_session_state(path, payload) def count_rune(text: str, target: str) -> int: @@ -220,70 +93,3 @@ def find_first_todo_line(capture: str) -> int: if "☒" in line or "☐" in line: return index return 999 - - -def verify_or_create_output(output_file: str, session_name: str, hash_value: str) -> str: - if output_file and file_exists(output_file) and Path(output_file).stat().st_size > 0: - return output_file - fallback = Path(f"/tmp/sa-{hash_value}-output-{session_name}-fallback.txt") - if tmux_has_session(session_name): - capture, _ = run_cmd("tmux", "capture-pane", "-t", session_name, "-p", "-S", "-300") - lines = trim_lines(capture)[:200] - fallback.write_text("\n".join(lines), encoding="utf-8") - if fallback.exists() and fallback.stat().st_size > 0: - return str(fallback) - expected = Path(f"/tmp/sa-{hash_value}-output-{session_name}.txt") - if expected.exists() and expected.stat().st_size > 0: - return str(expected) - return "" - - -def heartbeat_check(session: str, selected_agent: str) -> tuple[str, float, str, str]: - if not session: - return "error", 0.0, "", "no_session" - if not tmux_has_session(session): - return "error", 0.0, "", "session_not_found" - pane_pid = tmux_display(session, "#{pane_pid}") - if not pane_pid: - return "error", 0.0, "", "no_pane_pid" - pattern = "codex" if selected_agent == "codex" else "claude" - agent_pid = _find_agent_pid(pane_pid, pattern, 0) - prompt = _check_prompt_visible(session) - if not agent_pid: - return ("completed" if prompt == "true" else "dead"), 0.0, "", prompt - cpu_output, _ = run_cmd("ps", "-o", "%cpu=", "-p", agent_pid) - cpu = float(cpu_output.strip() or 0.0) - status = "alive" if int(cpu) > 0 else "idle" - if prompt == "true": - status = "completed" - return status, cpu, agent_pid, prompt - - -def _find_agent_pid(parent: str, pattern: str, depth: int) -> str: - if depth > 4: - return "" - output, code = run_cmd("pgrep", "-P", parent) - if code != 0: - return "" - for child in trim_lines(output): - child = child.strip() - if not child: - continue - command, _ = run_cmd("ps", "-o", "comm=", "-p", child) - if pattern.lower() in command.lower(): - return child - nested = _find_agent_pid(child, pattern, depth + 1) - if nested: - return nested - return "" - - -def _check_prompt_visible(session: str) -> str: - capture, _ = run_cmd("tmux", "capture-pane", "-t", session, "-p") - lines = trim_lines(capture)[-3:] - last = lines[-1].rstrip() if lines else "" - if re.search(r"❯\s*([0-9]+[smh]\s*)?[0-9]{1,2}:[0-9]{2}:[0-9]{2}\s*$", last): - return "true" - if re.search(r"(❯|\$|#|%)\s*$", last): - return "true" - return "false" diff --git a/source/src/story_automator/commands/tmux.py b/source/src/story_automator/commands/tmux.py index 0daa637..53eb773 100644 --- a/source/src/story_automator/commands/tmux.py +++ b/source/src/story_automator/commands/tmux.py @@ -1,25 +1,27 @@ from __future__ import annotations -import json import os -import re import time -from pathlib import Path from story_automator.core.review_verify import verify_code_review_completion +from story_automator.core.tmux_runtime import ( + agent_cli, + agent_type, + generate_session_name, + heartbeat_check, + runtime_mode, + session_status, + skill_prefix, + spawn_session, + tmux_has_session, + tmux_kill_session, + tmux_list_sessions, +) from story_automator.core.utils import ( - atomic_write, - command_exists, - file_exists, - filter_input_box, get_project_root, - iso_now, - md5_hex8, print_json, project_hash, project_slug, - read_text, - run_cmd, ) from story_automator.core.workflow_paths import ( create_story_workflow_paths, @@ -132,43 +134,11 @@ def _spawn(args: list[str]) -> int: print("--command is required", file=__import__("sys").stderr) return 1 session = generate_session_name(step, epic, story_id, cycle) - state_file = Path(f"/tmp/.sa-{project_hash()}-session-{session}-state.json") - if state_file.exists(): - state_file.unlink() - if not command_exists("tmux"): - print("tmux not found", file=__import__("sys").stderr) - return 1 root = get_project_root() - out, code = run_cmd( - "tmux", - "new-session", - "-d", - "-s", - session, - "-x", - "200", - "-y", - "50", - "-c", - root, - "-e", - "STORY_AUTOMATOR_CHILD=true", - "-e", - f"AI_AGENT={agent}", - "-e", - "CLAUDECODE=", - ) + out, code = spawn_session(session, command, agent, root, mode=runtime_mode()) if code != 0: print(out.strip(), file=__import__("sys").stderr) return 1 - if command: - if len(command) > 500: - script = Path(f"/tmp/sa-cmd-{session}.sh") - script.write_text("#!/bin/bash\n" + command + "\n", encoding="utf-8") - script.chmod(0o755) - run_cmd("tmux", "send-keys", "-t", session, f"bash {script}", "Enter") - else: - run_cmd("tmux", "send-keys", "-t", session, command, "Enter") print(session) return 0 @@ -306,18 +276,6 @@ def _build_cmd(args: list[str]) -> int: return 0 -def agent_type() -> str: - return os.environ.get("AI_AGENT", "claude") - - -def agent_cli(agent: str) -> str: - return "codex exec" if agent == "codex" else "claude --dangerously-skip-permissions" - - -def skill_prefix(agent: str) -> str: - return "none" if agent == "codex" else "bmad-" - - def _build_retro_prompt(epic_number: str, retro_paths, retro_extra: str) -> str: return ( ( @@ -360,48 +318,6 @@ def _build_retro_prompt(epic_number: str, retro_paths, retro_extra: str) -> str: def _automate_workflow_label(workflow_path: str) -> str: return "qa-generate-e2e-tests" if "qa-generate-e2e-tests" in workflow_path else "qa-generate-e2e-tests" - - -def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") -> str: - stamp = time.strftime("%y%m%d-%H%M%S", time.localtime()) - suffix = story_id.replace(".", "-") - name = f"sa-{project_slug()}-{stamp}-e{epic}-s{suffix}-{step}" - if cycle: - name += f"-r{cycle}" - return name - - -def tmux_has_session(session: str) -> bool: - return command_exists("tmux") and run_cmd("tmux", "has-session", "-t", session)[1] == 0 - - -def tmux_list_sessions(project_only: bool) -> tuple[list[str], int]: - if not command_exists("tmux"): - return ([], 1) - out, code = run_cmd("tmux", "list-sessions", "-F", "#{session_name}") - if code != 0: - return ([], code) - lines = [line.strip() for line in out.splitlines() if line.strip()] - prefix = f"sa-{project_slug()}-" - if project_only: - lines = [line for line in lines if line.startswith(prefix)] - else: - lines = [line for line in lines if line.startswith("sa-")] - return (lines, 0) - - -def tmux_kill_session(session: str) -> None: - if command_exists("tmux"): - run_cmd("tmux", "kill-session", "-t", session) - for path in ( - Path(f"/tmp/.sa-{project_hash()}-session-{session}-state.json"), - Path(f"/tmp/sa-{project_hash()}-output-{session}.txt"), - Path(f"/tmp/sa-cmd-{session}.sh"), - ): - if path.exists(): - path.unlink() - - def cmd_heartbeat_check(args: list[str]) -> int: if not args: print("error,0.0,,no_session") @@ -412,49 +328,11 @@ def cmd_heartbeat_check(args: list[str]) -> int: for idx, arg in enumerate(tail): if arg == "--agent" and idx + 1 < len(tail): agent = tail[idx + 1] - status, cpu, pid, prompt = heartbeat_check(session, agent) + status, cpu, pid, prompt = heartbeat_check(session, agent, project_root=get_project_root(), mode=runtime_mode()) print(f"{status},{cpu:.1f},{pid},{prompt}") return 0 -def heartbeat_check(session: str, agent: str) -> tuple[str, float, str, str]: - if not session: - return ("error", 0.0, "", "no_session") - if not tmux_has_session(session): - return ("error", 0.0, "", "session_not_found") - capture = filter_input_box(run_cmd("tmux", "capture-pane", "-t", session, "-p", "-S", "-40")[0]) - prompt = "true" if re.search(r"(❯|\$|#|%)\s*$", capture.splitlines()[-1] if capture.splitlines() else "") else "false" - pane_pid, code = run_cmd("tmux", "display-message", "-t", session, "-p", "#{pane_pid}") - if code != 0 or not pane_pid.strip(): - return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) - pane_pid = pane_pid.strip() - pattern = "codex" if agent == "codex" else "claude" - agent_pid = _find_agent_pid(pane_pid, pattern, 0) - if not agent_pid: - return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) - cpu_out, _ = run_cmd("ps", "-o", "%cpu=", "-p", agent_pid) - cpu = float(cpu_out.strip() or "0") - if int(cpu) > 0: - return ("alive", cpu, agent_pid, prompt) - return ("completed" if prompt == "true" else "idle", cpu, agent_pid, prompt) - - -def _find_agent_pid(parent: str, pattern: str, depth: int) -> str: - if depth > 4: - return "" - output, code = run_cmd("pgrep", "-P", parent) - if code != 0: - return "" - for child in [line.strip() for line in output.splitlines() if line.strip()]: - command, _ = run_cmd("ps", "-o", "comm=", "-p", child) - if pattern.lower() in command.lower(): - return child - nested = _find_agent_pid(child, pattern, depth + 1) - if nested: - return nested - return "" - - def cmd_codex_status_check(args: list[str]) -> int: return _status_check(args, codex=True) @@ -478,282 +356,9 @@ def _status_check(args: list[str], codex: bool) -> int: idx += 2 continue idx += 1 - status = session_status(session, full=full, codex=codex, project_root=project_root) + status = session_status(session, full=full, codex=codex, project_root=project_root, mode=runtime_mode()) print(",".join([status["status"], str(status["todos_done"]), str(status["todos_total"]), status["active_task"], str(status["wait_estimate"]), status["session_state"]])) return 0 if codex else (0 if status["status"] != "error" else 1) - - -def session_status( - session: str, - *, - full: bool, - codex: bool, - project_root: str | None = None, -) -> dict[str, str | int]: - if codex: - return _codex_session_status(session, full=full, project_root=project_root) - return _claude_session_status(session, full=full, project_root=project_root) - - -def _codex_session_status( - session: str, - *, - full: bool, - project_root: str | None = None, -) -> dict[str, str | int]: - if not session: - return {"status": "error", "todos_done": 0, "todos_total": 0, "active_task": "no_session", "wait_estimate": 30, "session_state": "error"} - if not tmux_has_session(session): - return {"status": "not_found", "todos_done": 0, "todos_total": 0, "active_task": "session_not_found", "wait_estimate": 0, "session_state": "not_found"} - capture = filter_input_box(run_cmd("tmux", "capture-pane", "-t", session, "-p", "-S", "-120")[0]) - todos_done = capture.count("☒") - todos_total = todos_done + capture.count("☐") - if re.search(r"tokens used|❯\s*(\d+[smh]\s*)?\d{1,2}:\d{2}:\d{2}\s*$", capture): - output = _write_capture(session, capture, project_root=project_root) - return {"status": "idle", "todos_done": max(1, todos_done), "todos_total": max(1, todos_total or 1), "active_task": output if full else "", "wait_estimate": 0, "session_state": "completed"} - heartbeat, cpu, _, prompt = heartbeat_check(session, "codex") - if heartbeat in {"alive", "idle"}: - active_task = extract_active_task(capture) or ("Codex working" if heartbeat == "alive" else "Codex running") - wait_estimate = 90 if heartbeat == "alive" else 60 - return {"status": "active", "todos_done": todos_done, "todos_total": todos_total, "active_task": active_task, "wait_estimate": wait_estimate, "session_state": "in_progress"} - if prompt == "true": - output = _write_capture(session, capture, project_root=project_root) - return {"status": "idle", "todos_done": max(1, todos_done), "todos_total": max(1, todos_total or 1), "active_task": output if full else "", "wait_estimate": 0, "session_state": "completed"} - if todos_done or todos_total: - output = _write_capture(session, capture, project_root=project_root) - return {"status": "idle", "todos_done": todos_done, "todos_total": todos_total, "active_task": output if full else "", "wait_estimate": 0, "session_state": "completed"} - output = _write_capture(session, capture, project_root=project_root) - return {"status": "idle", "todos_done": 0, "todos_total": 0, "active_task": output if full else "", "wait_estimate": 0, "session_state": "stuck"} - - -def _claude_session_status( - session: str, - *, - full: bool, - project_root: str | None = None, -) -> dict[str, str | int]: - if not session: - return {"status": "error", "todos_done": 0, "todos_total": 0, "active_task": "no_session", "wait_estimate": 30, "session_state": "error"} - - root = project_root or get_project_root() - state_path = _state_file(session, root) - - if not tmux_has_session(session): - state_path.unlink(missing_ok=True) - return {"status": "not_found", "todos_done": 0, "todos_total": 0, "active_task": "", "wait_estimate": 0, "session_state": "not_found"} - - pane_state = _pane_status(session) - if pane_state.startswith("crashed:"): - exit_code = pane_state.removeprefix("crashed:") - capture = filter_input_box(run_cmd("tmux", "capture-pane", "-t", session, "-p", "-S", "-200")[0]) - output = _write_capture(session, capture, project_root=root, max_lines=150) - state_path.unlink(missing_ok=True) - return { - "status": "crashed", - "todos_done": 0, - "todos_total": 0, - "active_task": output if full else "", - "wait_estimate": int(exit_code or "1"), - "session_state": "crashed", - } - - state = _load_tmux_state(state_path) - state["poll_count"] = int(state["poll_count"]) + 1 - - capture, code = run_cmd("tmux", "capture-pane", "-t", session, "-p", "-S", "-50") - capture = filter_input_box(capture) - if code != 0 or not capture: - return {"status": "error", "todos_done": 0, "todos_total": 0, "active_task": "capture_failed", "wait_estimate": 30, "session_state": "error"} - - current_status_time = _parse_statusline_time(capture) - todos_done = capture.count("☒") - todos_total = todos_done + capture.count("☐") - - if re.search(r"for [0-9]+m [0-9]+s", capture): - _save_tmux_state( - state_path, - poll_count=int(state["poll_count"]), - has_active=True, - done=int(state["last_todos_done"]), - total=int(state["last_todos_total"]), - status_time=current_status_time, - ) - output = _write_full_capture(session, project_root=root) if full else "" - return { - "status": "idle", - "todos_done": int(state["last_todos_done"]), - "todos_total": int(state["last_todos_total"]), - "active_task": output, - "wait_estimate": 0, - "session_state": "completed", - } - - pane_pid, pane_pid_code = run_cmd("tmux", "display-message", "-t", session, "-p", "#{pane_pid}") - claude_running = False - if pane_pid_code == 0 and pane_pid.strip(): - claude_running = bool(_find_agent_pid(pane_pid.strip(), "claude", 0)) - - activity_detected = bool( - re.search( - r"(?i)ctrl\+c to interrupt|Musing|Thinking|Working|Running|Loading|Beaming|Galloping|Razzmatazzing|Creating|⏺|✻|·", - capture, - ) - ) - - if activity_detected or claude_running: - active_task = extract_active_task(capture) or "Claude working" - wait_estimate = 60 - if todos_total > 0: - progress = 100 * todos_done // todos_total - if progress < 25: - wait_estimate = 90 - elif progress >= 75: - wait_estimate = 30 - _save_tmux_state( - state_path, - poll_count=int(state["poll_count"]), - has_active=True, - done=todos_done, - total=todos_total, - status_time=current_status_time, - ) - return { - "status": "active", - "todos_done": todos_done, - "todos_total": todos_total, - "active_task": active_task, - "wait_estimate": wait_estimate, - "session_state": "in_progress", - } - - session_state = "stuck" - if bool(state["has_ever_been_active"]): - session_state = "completed" - elif int(state["poll_count"]) <= 10: - session_state = "just_started" - elif current_status_time and str(state["last_statusline_time"]): - session_state = "just_started" if current_status_time != str(state["last_statusline_time"]) else "stuck" - elif current_status_time: - session_state = "just_started" - - output = _write_full_capture(session, project_root=root) if full else "" - if full and pane_state.startswith("exited:"): - session_state = "completed" - - _save_tmux_state( - state_path, - poll_count=int(state["poll_count"]), - has_active=bool(state["has_ever_been_active"]), - done=int(state["last_todos_done"]), - total=int(state["last_todos_total"]), - status_time=current_status_time, - ) - return { - "status": "idle", - "todos_done": int(state["last_todos_done"]), - "todos_total": int(state["last_todos_total"]), - "active_task": output, - "wait_estimate": 0, - "session_state": session_state, - } - - -def extract_active_task(capture: str) -> str: - pattern = re.compile(r"(?i)(Musing|Thinking|Working|Running|Loading|Creating|Galloping|Beaming|Razzmatazzing)") - active = "" - for line in capture.splitlines(): - if pattern.search(line): - active = line.strip() - active = re.sub(r"[·✳⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✶✻]", "", active) - active = re.sub(r"\(ctrl\+c.*", "", active).strip() - return active[:80] - - -def _state_file(session: str, project_root: str | None = None) -> Path: - return Path(f"/tmp/.sa-{project_hash(project_root)}-session-{session}-state.json") - - -def _load_tmux_state(path: Path) -> dict[str, str | int | bool]: - state: dict[str, str | int | bool] = { - "poll_count": 0, - "has_ever_been_active": False, - "last_todos_done": 0, - "last_todos_total": 0, - "last_statusline_time": "", - } - if not path.exists(): - return state - try: - raw = json.loads(read_text(path)) - except (OSError, json.JSONDecodeError): - return state - state["poll_count"] = int(raw.get("pollCount", 0) or 0) - state["has_ever_been_active"] = bool(raw.get("hasEverBeenActive", False)) - state["last_todos_done"] = int(raw.get("lastTodosDone", 0) or 0) - state["last_todos_total"] = int(raw.get("lastTodosTotal", 0) or 0) - state["last_statusline_time"] = str(raw.get("lastStatuslineTime", "") or "") - return state - - -def _save_tmux_state( - path: Path, - *, - poll_count: int, - has_active: bool, - done: int, - total: int, - status_time: str, -) -> None: - atomic_write( - path, - json.dumps( - { - "pollCount": poll_count, - "hasEverBeenActive": has_active, - "lastTodosDone": done, - "lastTodosTotal": total, - "lastStatuslineTime": status_time, - "lastPollAt": iso_now(), - } - ), - ) - - -def _pane_status(session: str) -> str: - pane_dead, _ = run_cmd("tmux", "display-message", "-t", session, "-p", "#{pane_dead}") - exit_status, _ = run_cmd("tmux", "display-message", "-t", session, "-p", "#{pane_dead_status}") - if pane_dead.strip() == "1": - if exit_status.strip() and exit_status.strip() != "0": - return f"crashed:{exit_status.strip()}" - return "exited:0" - return "alive" - - -def _parse_statusline_time(capture: str) -> str: - matches = re.findall(r"\| [0-9]{2}:[0-9]{2}:[0-9]{2}", capture) - if not matches: - return "" - return matches[-1].replace("|", "").strip() - - -def _write_full_capture(session: str, *, project_root: str | None = None) -> str: - capture = filter_input_box(run_cmd("tmux", "capture-pane", "-t", session, "-p", "-S", "-300")[0]) - return _write_capture(session, capture, project_root=project_root) - - -def _write_capture( - session: str, - capture: str, - *, - project_root: str | None = None, - max_lines: int = 200, -) -> str: - path = Path(f"/tmp/sa-{project_hash(project_root)}-output-{session}.txt") - lines = capture.splitlines()[:max_lines] - atomic_write(path, "\n".join(lines)) - return str(path) - - def cmd_monitor_session(args: list[str]) -> int: if not args: print("Usage: monitor-session [options]", file=__import__("sys").stderr) @@ -814,13 +419,13 @@ def cmd_monitor_session(args: list[str]) -> int: for _poll in range(1, max_polls + 1): if time.time() - start >= timeout_minutes * 60: return _emit_monitor(json_output, "timeout", last_done, last_total, "", f"exceeded_{timeout_minutes}m") - status = session_status(session, full=False, codex=agent == "codex", project_root=project_root) + status = session_status(session, full=False, codex=agent == "codex", project_root=project_root, mode=runtime_mode()) if int(status["todos_done"]) or int(status["todos_total"]): last_done = int(status["todos_done"]) last_total = int(status["todos_total"]) state = str(status["session_state"]) if state == "completed": - output = session_status(session, full=True, codex=agent == "codex", project_root=project_root)["active_task"] + output = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())["active_task"] if workflow == "review" and story_key: verified = verify_code_review_completion(project_root, story_key) if bool(verified.get("verified")): @@ -828,7 +433,7 @@ def cmd_monitor_session(args: list[str]) -> int: return _emit_monitor(json_output, "incomplete", last_done, last_total, str(output), "workflow_not_verified") return _emit_monitor(json_output, "completed", last_done, last_total, str(output), "normal_completion") if state == "crashed": - crashed = session_status(session, full=True, codex=agent == "codex", project_root=project_root) + crashed = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode()) return _emit_monitor( json_output, "crashed", @@ -838,12 +443,12 @@ def cmd_monitor_session(args: list[str]) -> int: f"exit_code_{int(crashed['wait_estimate'])}", ) if state == "stuck": - output = session_status(session, full=True, codex=agent == "codex", project_root=project_root)["active_task"] + output = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())["active_task"] return _emit_monitor(json_output, "stuck", 0, 0, str(output), "never_active") if state == "not_found": return _emit_monitor(json_output, "not_found", last_done, last_total, "", "session_gone") time.sleep(min(180 if agent == "codex" else 120, max(5, int(status["wait_estimate"])))) - output = session_status(session, full=True, codex=agent == "codex", project_root=project_root)["active_task"] + output = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())["active_task"] return _emit_monitor(json_output, "timeout", last_done, last_total, str(output), "max_polls_exceeded") diff --git a/source/src/story_automator/core/tmux_runtime.py b/source/src/story_automator/core/tmux_runtime.py new file mode 100644 index 0000000..49ea7db --- /dev/null +++ b/source/src/story_automator/core/tmux_runtime.py @@ -0,0 +1,1188 @@ +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from .utils import ( + atomic_write, + command_exists, + file_exists, + filter_input_box, + get_project_root, + iso_now, + project_hash, + project_slug, + read_text, + run_cmd, +) + +STATE_SCHEMA_VERSION = 1 +DEFAULT_WIDTH = 200 +DEFAULT_HEIGHT = 50 +REMAIN_ON_EXIT = "on" +PLACEHOLDER_COMMAND = ("/bin/sleep", "86400") +ARTIFACT_TTL_SECONDS = 24 * 60 * 60 +RECONCILE_GRACE_SECONDS = 1.0 +RUNNER_MODE_ENV = "SA_TMUX_RUNTIME" +VALID_RUNTIME_MODES = {"legacy", "runner", "auto"} +SIGNAL_EXIT_CODES = {130, 131, 143} + + +@dataclass(frozen=True) +class SessionPaths: + state: Path + command: Path + runner: Path + output: Path + + +@dataclass(frozen=True) +class PaneSnapshot: + exists: bool + pane_id: str + pane_pid: int + dead: bool + dead_status: int | None + + +def runtime_mode() -> str: + value = os.environ.get(RUNNER_MODE_ENV, "auto").strip().lower() + return value if value in VALID_RUNTIME_MODES else "auto" + + +def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") -> str: + stamp = time.strftime("%y%m%d-%H%M%S", time.localtime()) + suffix = story_id.replace(".", "-") + name = f"sa-{project_slug()}-{stamp}-e{epic}-s{suffix}-{step}" + if cycle: + name += f"-r{cycle}" + return name + + +def agent_type() -> str: + return os.environ.get("AI_AGENT", "claude") + + +def agent_cli(agent: str) -> str: + return "codex exec" if agent == "codex" else "claude --dangerously-skip-permissions" + + +def skill_prefix(agent: str) -> str: + return "none" if agent == "codex" else "/bmad-bmm-" + + +def session_paths(session: str, project_root: str | None = None) -> SessionPaths: + hash_value = project_hash(project_root) + return SessionPaths( + state=Path(f"/tmp/.sa-{hash_value}-session-{session}-state.json"), + command=Path(f"/tmp/.sa-{hash_value}-session-{session}-command.sh"), + runner=Path(f"/tmp/.sa-{hash_value}-session-{session}-runner.sh"), + output=Path(f"/tmp/sa-{hash_value}-output-{session}.txt"), + ) + + +def tmux_has_session(session: str) -> bool: + return command_exists("tmux") and run_cmd("tmux", "has-session", "-t", session)[1] == 0 + + +def tmux_display(session: str, fmt: str) -> str: + output, _ = run_cmd("tmux", "display-message", "-t", session, "-p", fmt) + return output.strip() + + +def tmux_show_environment(session: str, key: str) -> str: + output, code = run_cmd("tmux", "show-environment", "-t", session, key) + if code != 0: + return "" + parts = output.strip().split("=", 1) + return parts[1] if len(parts) == 2 else "" + + +def tmux_list_sessions(project_only: bool) -> tuple[list[str], int]: + if not command_exists("tmux"): + return ([], 1) + output, code = run_cmd("tmux", "list-sessions", "-F", "#{session_name}") + if code != 0: + return ([], code) + sessions = [line.strip() for line in output.splitlines() if line.strip().startswith("sa-")] + if project_only: + prefix = f"sa-{project_slug()}-" + sessions = [line for line in sessions if line.startswith(prefix)] + return (sessions, 0) + + +def load_session_state(path: str | Path) -> dict[str, object]: + target = Path(path) + if not target.exists(): + return {} + try: + raw = json.loads(read_text(target)) + except (OSError, json.JSONDecodeError): + return {} + return raw if isinstance(raw, dict) else {} + + +def save_session_state(path: str | Path, payload: dict[str, object]) -> None: + _write_private_text(Path(path), json.dumps(payload, separators=(",", ":")), 0o600) + + +def update_session_state(path: str | Path, **updates: object) -> dict[str, object]: + target = Path(path) + state = load_session_state(target) + state.update(updates) + state["updatedAt"] = str(state.get("updatedAt") or iso_now()) + save_session_state(target, state) + return state + + +def cleanup_runtime_artifacts(session: str, project_root: str | None = None) -> None: + paths = session_paths(session, project_root) + for path in (paths.state, paths.command, paths.runner, paths.output): + path.unlink(missing_ok=True) + fallback = paths.output.with_name(f"{paths.output.stem}-fallback{paths.output.suffix}") + fallback.unlink(missing_ok=True) + + +def cleanup_stale_terminal_artifacts(project_root: str | None = None, ttl_seconds: int = ARTIFACT_TTL_SECONDS) -> None: + root_hash = project_hash(project_root) + cutoff = time.time() - ttl_seconds + tmp_dir = Path("/tmp") + state_paths = tmp_dir.glob(f".sa-{root_hash}-session-*-state.json") + for state_path in state_paths: + try: + if state_path.stat().st_mtime > cutoff: + continue + except OSError: + continue + state = load_session_state(state_path) + session = _session_name_from_state_path(state_path) + if not session: + state_path.unlink(missing_ok=True) + continue + if _is_terminal_state(state): + cleanup_runtime_artifacts(session, project_root) + for pattern in ( + f".sa-{root_hash}-session-*-command.sh", + f".sa-{root_hash}-session-*-runner.sh", + f"sa-{root_hash}-output-*.txt", + ): + for path in tmp_dir.glob(pattern): + try: + if path.stat().st_mtime <= cutoff: + path.unlink(missing_ok=True) + except OSError: + continue + + +def tmux_kill_session(session: str, project_root: str | None = None) -> None: + if command_exists("tmux"): + run_cmd("tmux", "kill-session", "-t", session) + cleanup_runtime_artifacts(session, project_root) + + +def spawn_session( + session: str, + command: str, + selected_agent: str, + project_root: str | None = None, + mode: str | None = None, +) -> tuple[str, int]: + resolved_mode = _resolve_spawn_mode(mode) + if resolved_mode == "legacy": + return _spawn_legacy(session, command, selected_agent, project_root) + return _spawn_runner(session, command, selected_agent, project_root) + + +def heartbeat_check( + session: str, + selected_agent: str, + *, + project_root: str | None = None, + mode: str | None = None, +) -> tuple[str, float, str, str]: + if not session: + return ("error", 0.0, "", "no_session") + + resolved_mode = _status_mode(session, project_root, mode) + if resolved_mode == "legacy": + return _legacy_heartbeat_check(session, selected_agent) + + prompt = _check_prompt_visible(session) if tmux_has_session(session) else "false" + state = load_session_state(session_paths(session, project_root).state) + if not state: + return ("error", 0.0, "", "state_missing") + + if _is_terminal_state(state): + pid = str(state.get("childPid") or "") + status = "completed" if str(state.get("result") or "") == "success" else "dead" + return (status, 0.0, pid, prompt) + + child_pid = _safe_int(state.get("childPid")) + if child_pid <= 0: + return ("idle", 0.0, "", prompt) + + cpu = _process_cpu(child_pid) + if _pid_alive(child_pid): + return (("alive" if int(cpu) > 0 else "idle"), cpu, str(child_pid), prompt) + + time.sleep(RECONCILE_GRACE_SECONDS) + status = session_status(session, full=False, codex=selected_agent == "codex", project_root=project_root, mode=resolved_mode) + public = str(status["session_state"]) + if public == "completed": + return ("completed", 0.0, str(child_pid), prompt) + if public in {"crashed", "stuck", "not_found"}: + return ("dead", 0.0, str(child_pid), prompt) + return ("idle", 0.0, str(child_pid), prompt) + + +def session_status( + session: str, + *, + full: bool, + codex: bool, + project_root: str | None = None, + mode: str | None = None, +) -> dict[str, str | int]: + resolved_mode = _status_mode(session, project_root, mode) + if resolved_mode == "legacy": + return _legacy_session_status(session, full=full, codex=codex, project_root=project_root) + return _runner_session_status(session, full=full, codex=codex, project_root=project_root) + + +def pane_status(session: str) -> str: + pane = _pane_snapshot(session) + if not pane.exists: + return "missing" + if pane.dead: + if pane.dead_status not in (None, 0): + return f"crashed:{pane.dead_status}" + return "exited:0" + return "alive" + + +def verify_or_create_output(output_file: str, session_name: str, hash_value: str, *, project_root: str | None = None) -> str: + if output_file and file_exists(output_file) and Path(output_file).stat().st_size > 0: + return output_file + expected = Path(f"/tmp/sa-{hash_value}-output-{session_name}.txt") + if tmux_has_session(session_name): + capture = _capture_text(session_name, start=-300) + if capture: + _write_private_text(expected, "\n".join(capture.splitlines()[:200]), 0o600) + if expected.stat().st_size > 0: + return str(expected) + if expected.exists() and expected.stat().st_size > 0: + return str(expected) + if project_root is not None: + fallback = session_paths(session_name, project_root).output + if fallback.exists() and fallback.stat().st_size > 0: + return str(fallback) + return "" + + +def extract_active_task(capture: str) -> str: + pattern = re.compile(r"(?i)(Musing|Thinking|Working|Running|Loading|Creating|Galloping|Beaming|Razzmatazzing|ctrl\+c to interrupt|✻|·|⏺)") + active = "" + for line in capture.splitlines(): + if pattern.search(line): + active = line.strip() + active = re.sub(r"[·✳⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✶✻⏺]", "", active) + active = re.sub(r"\(ctrl\+c.*", "", active).strip() + return active[:80] + + +def detect_codex_session(session: str, capture: str) -> str: + if tmux_show_environment(session, "AI_AGENT") == "codex": + return "codex" + if re.search(r"(?i)OpenAI Codex|codex exec|gpt-[0-9]+-codex|tokens used|codex-cli", capture): + return "codex" + return "claude" + + +def estimate_wait(task: str, done: int, total: int) -> int: + lower = task.lower() + if re.search(r"loading|reading|searching|parsing|launching|starting", lower): + return 30 + if re.search(r"presenting|waiting|menu|select|choose", lower): + return 15 + if re.search(r"running tests|testing|building|compiling|installing", lower): + return 120 + if re.search(r"writing|editing|updating|creating|fixing", lower): + return 60 + if total > 0: + progress = 100 * done // total + if progress < 25: + return 90 + if progress < 50: + return 75 + if progress < 75: + return 60 + return 30 + return 60 + + +def _spawn_runner(session: str, command: str, selected_agent: str, project_root: str | None) -> tuple[str, int]: + if not command_exists("tmux"): + return ("tmux not found\n", 1) + bash_path = shutil.which("bash") + if not bash_path: + return ("bash not found\n", 1) + + root = Path(project_root or get_project_root()).resolve() + cleanup_stale_terminal_artifacts(str(root)) + paths = session_paths(session, str(root)) + cleanup_runtime_artifacts(session, str(root)) + + _write_private_text(paths.command, _command_file_content(command), 0o700) + _write_private_text(paths.runner, _runner_file_content(paths, bash_path, str(root)), 0o700) + + create_out, create_code = run_cmd( + "tmux", + "new-session", + "-d", + "-s", + session, + "-x", + str(DEFAULT_WIDTH), + "-y", + str(DEFAULT_HEIGHT), + "-c", + str(root), + "-e", + "STORY_AUTOMATOR_CHILD=true", + "-e", + f"AI_AGENT={selected_agent}", + "-e", + "CLAUDECODE=", + "-e", + "BASH_ENV=", + *PLACEHOLDER_COMMAND, + ) + if create_code != 0: + cleanup_runtime_artifacts(session, str(root)) + return (create_out, create_code) + + pane_id = tmux_display(session, "#{pane_id}") + if not pane_id: + tmux_kill_session(session, str(root)) + return ("failed to resolve tmux pane id\n", 1) + + set_out, set_code = run_cmd("tmux", "set-option", "-t", pane_id, "remain-on-exit", REMAIN_ON_EXIT) + if set_code != 0: + tmux_kill_session(session, str(root)) + return (set_out, set_code) + + created_at = iso_now() + pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) + if pane_pid <= 0: + tmux_kill_session(session, str(root)) + return ("failed to resolve tmux pane pid\n", 1) + + save_session_state( + paths.state, + { + "schemaVersion": STATE_SCHEMA_VERSION, + "session": session, + "agent": selected_agent, + "projectRoot": str(root), + "paneId": pane_id, + "panePid": pane_pid, + "runnerPid": "", + "childPid": "", + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": created_at, + "startedAt": "", + "finishedAt": "", + "updatedAt": created_at, + "lifecycle": "created", + "result": "", + "exitCode": "", + "failureReason": "", + }, + ) + + respawn_out, respawn_code = run_cmd("tmux", "respawn-pane", "-k", "-t", pane_id, bash_path, str(paths.runner)) + if respawn_code != 0: + tmux_kill_session(session, str(root)) + return (respawn_out, respawn_code) + + time.sleep(0.1) + respawned_pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) + if respawned_pane_pid <= 0: + tmux_kill_session(session, str(root)) + return ("failed to resolve respawned tmux pane pid\n", 1) + + update_session_state( + paths.state, + paneId=pane_id, + panePid=respawned_pane_pid, + updatedAt=iso_now(), + ) + return ("", 0) + + +def _spawn_legacy(session: str, command: str, selected_agent: str, project_root: str | None) -> tuple[str, int]: + if not command_exists("tmux"): + return ("tmux not found\n", 1) + root = project_root or get_project_root() + paths = session_paths(session, root) + paths.state.unlink(missing_ok=True) + output, code = run_cmd( + "tmux", + "new-session", + "-d", + "-s", + session, + "-x", + str(DEFAULT_WIDTH), + "-y", + str(DEFAULT_HEIGHT), + "-c", + root, + "-e", + "STORY_AUTOMATOR_CHILD=true", + "-e", + f"AI_AGENT={selected_agent}", + "-e", + "CLAUDECODE=", + ) + if code != 0: + return (output, code) + if len(command) > 500: + _write_private_text(paths.command, "#!/bin/bash\n" + command + "\n", 0o700) + run_cmd("tmux", "send-keys", "-t", session, f"bash {paths.command}", "Enter") + else: + run_cmd("tmux", "send-keys", "-t", session, command, "Enter") + return ("", 0) + + +def _runner_session_status( + session: str, + *, + full: bool, + codex: bool, + project_root: str | None, +) -> dict[str, str | int]: + root = str(Path(project_root or get_project_root()).resolve()) + paths = session_paths(session, root) + state = load_session_state(paths.state) + if not state: + if tmux_has_session(session): + return _legacy_session_status(session, full=full, codex=codex, project_root=root) + return _not_found_status() + + if _is_terminal_state(state): + return _terminal_runner_status(session, state, full=full, project_root=root) + + pane = _pane_snapshot(session) + child_pid = _safe_int(state.get("childPid")) + runner_pid = _safe_int(state.get("runnerPid")) + child_alive = child_pid > 0 and _pid_alive(child_pid) + runner_alive = runner_pid > 0 and _pid_alive(runner_pid) + + if str(state.get("lifecycle") or "") == "running" and not child_alive: + time.sleep(RECONCILE_GRACE_SECONDS) + refreshed = load_session_state(paths.state) + if _is_terminal_state(refreshed): + return _terminal_runner_status(session, refreshed, full=full, project_root=root) + state = _reconcile_runner_state(paths, refreshed or state, pane) + if _is_terminal_state(state): + return _terminal_runner_status(session, state, full=full, project_root=root) + child_pid = _safe_int(state.get("childPid")) + runner_pid = _safe_int(state.get("runnerPid")) + child_alive = child_pid > 0 and _pid_alive(child_pid) + runner_alive = runner_pid > 0 and _pid_alive(runner_pid) + else: + state = _reconcile_runner_state(paths, state, pane) + if _is_terminal_state(state): + return _terminal_runner_status(session, state, full=full, project_root=root) + + capture = _capture_text(session, start=-120) + todos_done, todos_total = _todo_counts(capture) + active_task = extract_active_task(capture) + lifecycle = str(state.get("lifecycle") or "") + + if lifecycle == "running" and child_alive: + label = active_task or ("Codex working" if codex else "Claude working") + return { + "status": "active", + "todos_done": todos_done, + "todos_total": todos_total, + "active_task": label, + "wait_estimate": estimate_wait(label, todos_done, todos_total), + "session_state": "in_progress", + } + + if lifecycle in {"created", "launching"} or runner_alive or pane.exists: + label = active_task or ("launching codex" if codex else "launching claude") + return { + "status": "idle", + "todos_done": todos_done, + "todos_total": todos_total, + "active_task": label, + "wait_estimate": 15, + "session_state": "just_started", + } + + output = _export_output_file(session, root) if full else "" + return { + "status": "idle", + "todos_done": todos_done, + "todos_total": todos_total, + "active_task": output, + "wait_estimate": 0, + "session_state": "stuck", + } + + +def _terminal_runner_status( + session: str, + state: dict[str, object], + *, + full: bool, + project_root: str, +) -> dict[str, str | int]: + paths = session_paths(session, project_root) + text = _capture_text(session, start=-300) or _output_text(paths.output) + todos_done, todos_total = _todo_counts(text) + result = str(state.get("result") or "") + failure_reason = str(state.get("failureReason") or "") + exit_code = _safe_int(state.get("exitCode")) + output = _export_output_file(session, project_root) if full else "" + + if result == "success": + return { + "status": "idle", + "todos_done": max(todos_done, 1 if text else 0), + "todos_total": max(todos_total, 1 if text else 0), + "active_task": output, + "wait_estimate": 0, + "session_state": "completed", + } + + if result == "unknown" and failure_reason == "launch_never_succeeded": + return { + "status": "idle", + "todos_done": todos_done, + "todos_total": todos_total, + "active_task": output, + "wait_estimate": 0, + "session_state": "stuck", + } + + return { + "status": "crashed", + "todos_done": todos_done, + "todos_total": todos_total, + "active_task": output, + "wait_estimate": exit_code or 1, + "session_state": "crashed", + } + + +def _reconcile_runner_state(paths: SessionPaths, state: dict[str, object], pane: PaneSnapshot | None = None) -> dict[str, object]: + if _is_terminal_state(state): + return state + + pane_snapshot = pane or _pane_snapshot(str(state.get("session") or "")) + lifecycle = str(state.get("lifecycle") or "") + child_pid = _safe_int(state.get("childPid")) + runner_pid = _safe_int(state.get("runnerPid")) + child_alive = child_pid > 0 and _pid_alive(child_pid) + runner_alive = runner_pid > 0 and _pid_alive(runner_pid) + + if pane_snapshot.exists and pane_snapshot.dead: + result, reason = _result_from_exit_code(pane_snapshot.dead_status) + finished = iso_now() + save_session_state( + paths.state, + { + **state, + "finishedAt": str(state.get("finishedAt") or finished), + "updatedAt": finished, + "lifecycle": "finished", + "result": result, + "exitCode": pane_snapshot.dead_status if pane_snapshot.dead_status is not None else 0, + "failureReason": reason, + }, + ) + return load_session_state(paths.state) + + if lifecycle == "running" and not child_alive and not runner_alive: + finished = iso_now() + save_session_state( + paths.state, + { + **state, + "finishedAt": str(state.get("finishedAt") or finished), + "updatedAt": finished, + "lifecycle": "finished", + "result": "interrupted", + "exitCode": _safe_int(state.get("exitCode")) or 1, + "failureReason": "runner_finalization_missing", + }, + ) + return load_session_state(paths.state) + + if lifecycle in {"created", "launching"} and not pane_snapshot.exists and _state_age_seconds(state) > RECONCILE_GRACE_SECONDS: + save_session_state( + paths.state, + { + **state, + "updatedAt": iso_now(), + "lifecycle": "finished", + "result": "unknown", + "exitCode": "", + "failureReason": "launch_never_succeeded", + "finishedAt": iso_now(), + }, + ) + return load_session_state(paths.state) + + return state + + +def _legacy_session_status( + session: str, + *, + full: bool, + codex: bool, + project_root: str | None, +) -> dict[str, str | int]: + if codex: + return _legacy_codex_session_status(session, full=full, project_root=project_root) + return _legacy_claude_session_status(session, full=full, project_root=project_root) + + +def _legacy_codex_session_status( + session: str, + *, + full: bool, + project_root: str | None, +) -> dict[str, str | int]: + if not session: + return {"status": "error", "todos_done": 0, "todos_total": 0, "active_task": "no_session", "wait_estimate": 30, "session_state": "error"} + if not tmux_has_session(session): + return _not_found_status() + capture = _capture_text(session, start=-120) + todos_done, todos_total = _todo_counts(capture) + if re.search(r"tokens used|❯\s*(\d+[smh]\s*)?\d{1,2}:\d{2}:\d{2}\s*$", capture): + output = _write_capture(session, capture, project_root=project_root) + return {"status": "idle", "todos_done": max(1, todos_done), "todos_total": max(1, todos_total or 1), "active_task": output if full else "", "wait_estimate": 0, "session_state": "completed"} + heartbeat, cpu, _, prompt = _legacy_heartbeat_check(session, "codex") + if heartbeat == "alive": + label = extract_active_task(capture) or f"Codex working (CPU: {cpu:.1f}%)" + return {"status": "active", "todos_done": todos_done, "todos_total": todos_total, "active_task": label, "wait_estimate": 90, "session_state": "in_progress"} + if prompt == "true": + output = _write_capture(session, capture, project_root=project_root) + return {"status": "idle", "todos_done": max(1, todos_done), "todos_total": max(1, todos_total or 1), "active_task": output if full else "", "wait_estimate": 0, "session_state": "completed"} + if todos_done or todos_total: + output = _write_capture(session, capture, project_root=project_root) + return {"status": "idle", "todos_done": todos_done, "todos_total": todos_total, "active_task": output if full else "", "wait_estimate": 0, "session_state": "completed"} + output = _write_capture(session, capture, project_root=project_root) + return {"status": "idle", "todos_done": 0, "todos_total": 0, "active_task": output if full else "", "wait_estimate": 0, "session_state": "stuck"} + + +def _legacy_claude_session_status( + session: str, + *, + full: bool, + project_root: str | None, +) -> dict[str, str | int]: + if not session: + return {"status": "error", "todos_done": 0, "todos_total": 0, "active_task": "no_session", "wait_estimate": 30, "session_state": "error"} + + root = project_root or get_project_root() + state_path = session_paths(session, root).state + + if not tmux_has_session(session): + state_path.unlink(missing_ok=True) + return _not_found_status() + + current_pane_state = pane_status(session) + if current_pane_state.startswith("crashed:"): + exit_code = current_pane_state.removeprefix("crashed:") + capture = _capture_text(session, start=-200) + output = _write_capture(session, capture, project_root=root, max_lines=150) + state_path.unlink(missing_ok=True) + return { + "status": "crashed", + "todos_done": 0, + "todos_total": 0, + "active_task": output if full else "", + "wait_estimate": int(exit_code or "1"), + "session_state": "crashed", + } + + state = _load_legacy_state(state_path) + state["poll_count"] = int(state["poll_count"]) + 1 + + capture = _capture_text(session, start=-50) + if not capture: + return {"status": "error", "todos_done": 0, "todos_total": 0, "active_task": "capture_failed", "wait_estimate": 30, "session_state": "error"} + + current_status_time = _parse_statusline_time(capture) + todos_done, todos_total = _todo_counts(capture) + + if re.search(r"for [0-9]+m [0-9]+s", capture): + _save_legacy_state( + state_path, + poll_count=int(state["poll_count"]), + has_active=True, + done=int(state["last_todos_done"]), + total=int(state["last_todos_total"]), + status_time=current_status_time, + ) + output = _write_full_capture(session, project_root=root) if full else "" + return { + "status": "idle", + "todos_done": int(state["last_todos_done"]), + "todos_total": int(state["last_todos_total"]), + "active_task": output, + "wait_estimate": 0, + "session_state": "completed", + } + + pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) + claude_running = pane_pid > 0 and run_cmd("pgrep", "-P", str(pane_pid), "-f", "claude")[1] == 0 + activity_detected = bool( + re.search( + r"(?i)ctrl\+c to interrupt|Musing|Thinking|Working|Running|Loading|Beaming|Galloping|Razzmatazzing|Creating|⏺|✻|·", + capture, + ) + ) + + if activity_detected or claude_running: + active_task = extract_active_task(capture) or "Claude working" + wait_estimate = estimate_wait(active_task, todos_done, todos_total) + _save_legacy_state( + state_path, + poll_count=int(state["poll_count"]), + has_active=True, + done=todos_done, + total=todos_total, + status_time=current_status_time, + ) + return { + "status": "active", + "todos_done": todos_done, + "todos_total": todos_total, + "active_task": active_task, + "wait_estimate": wait_estimate, + "session_state": "in_progress", + } + + session_state = "stuck" + if bool(state["has_ever_been_active"]): + session_state = "completed" + elif int(state["poll_count"]) <= 10: + session_state = "just_started" + elif current_status_time and str(state["last_statusline_time"]): + session_state = "just_started" if current_status_time != str(state["last_statusline_time"]) else "stuck" + elif current_status_time: + session_state = "just_started" + + output = _write_full_capture(session, project_root=root) if full else "" + if full and current_pane_state.startswith("exited:"): + session_state = "completed" + + _save_legacy_state( + state_path, + poll_count=int(state["poll_count"]), + has_active=bool(state["has_ever_been_active"]), + done=int(state["last_todos_done"]), + total=int(state["last_todos_total"]), + status_time=current_status_time, + ) + return { + "status": "idle", + "todos_done": int(state["last_todos_done"]), + "todos_total": int(state["last_todos_total"]), + "active_task": output, + "wait_estimate": 0, + "session_state": session_state, + } + + +def _legacy_heartbeat_check(session: str, selected_agent: str) -> tuple[str, float, str, str]: + if not session: + return ("error", 0.0, "", "no_session") + if not tmux_has_session(session): + return ("error", 0.0, "", "session_not_found") + capture = _capture_text(session, start=-40) + lines = capture.splitlines() + last_line = lines[-1] if lines else "" + prompt = "true" if re.search(r"(❯|\$|#|%)\s*$", last_line) else "false" + pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) + if pane_pid <= 0: + return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) + pattern = "codex" if selected_agent == "codex" else "claude" + agent_pid = _find_agent_pid(str(pane_pid), pattern, 0) + if not agent_pid: + return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) + cpu = _process_cpu(int(agent_pid)) + status = "alive" if int(cpu) > 0 else "idle" + if prompt == "true": + status = "completed" + return (status, cpu, agent_pid, prompt) + + +def _status_mode(session: str, project_root: str | None, mode: str | None) -> str: + configured = (mode or runtime_mode()).strip().lower() + if configured not in VALID_RUNTIME_MODES: + configured = "auto" + if configured in {"legacy", "runner"}: + return configured + state = load_session_state(session_paths(session, project_root).state) + if int(state.get("schemaVersion") or 0) == STATE_SCHEMA_VERSION: + return "runner" + return "legacy" + + +def _resolve_spawn_mode(mode: str | None) -> str: + configured = (mode or runtime_mode()).strip().lower() + if configured == "legacy": + return "legacy" + return "runner" + + +def _runner_file_content(paths: SessionPaths, bash_path: str, project_root: str) -> str: + state_file = shlex.quote(str(paths.state)) + command_file = shlex.quote(str(paths.command)) + root = shlex.quote(project_root) + python_bin = shlex.quote(sys.executable) + return f"""#!/usr/bin/env bash +set -euo pipefail + +cd -- {root} + +STATE_FILE={state_file} +COMMAND_FILE={command_file} +PYTHON_BIN={python_bin} + +write_state() {{ + STATE_LIFECYCLE="$1" \\ + STATE_RESULT="$2" \\ + STATE_EXIT_CODE="$3" \\ + STATE_FAILURE_REASON="$4" \\ + STATE_RUNNER_PID="${{5:-}}" \\ + STATE_CHILD_PID="${{6:-}}" \\ + STATE_STARTED_AT="${{7:-}}" \\ + STATE_FINISHED_AT="${{8:-}}" \\ + STATE_FILE="$STATE_FILE" \\ + "$PYTHON_BIN" <<'PY' +from __future__ import annotations +import json +import os +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +path = Path(os.environ["STATE_FILE"]) +state = {{}} +if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + state = loaded + except Exception: + state = {{}} + +def now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + +state["schemaVersion"] = {STATE_SCHEMA_VERSION} +state["lifecycle"] = os.environ["STATE_LIFECYCLE"] +state["result"] = os.environ["STATE_RESULT"] +state["failureReason"] = os.environ["STATE_FAILURE_REASON"] +state["updatedAt"] = now_iso() + +runner_pid = os.environ.get("STATE_RUNNER_PID", "") +child_pid = os.environ.get("STATE_CHILD_PID", "") +exit_code = os.environ.get("STATE_EXIT_CODE", "") +started_at = os.environ.get("STATE_STARTED_AT", "") +finished_at = os.environ.get("STATE_FINISHED_AT", "") + +if runner_pid: + state["runnerPid"] = int(runner_pid) +if child_pid: + state["childPid"] = int(child_pid) +if started_at: + state["startedAt"] = started_at +if finished_at: + state["finishedAt"] = finished_at +if exit_code != "": + state["exitCode"] = int(exit_code) + +path.parent.mkdir(parents=True, exist_ok=True) +fd, tmp_name = tempfile.mkstemp(prefix=f".{{path.name}}.", suffix=".tmp", dir=str(path.parent)) +try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(state, separators=(",", ":"))) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_name, path) + os.chmod(path, 0o600) +finally: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass +PY +}} + +now_iso() {{ + date -u +%Y-%m-%dT%H:%M:%SZ +}} + +runner_pid=$$ +write_state "launching" "" "" "" "$runner_pid" "" "" "" + +if [[ ! -f "$COMMAND_FILE" ]]; then + finished_at="$(now_iso)" + write_state "finished" "spawn_error" "127" "command_file_missing" "$runner_pid" "" "" "$finished_at" + exit 127 +fi + +set +e +"{bash_path}" "$COMMAND_FILE" & +child_pid=$! +started_at="$(now_iso)" +write_state "running" "" "" "" "$runner_pid" "$child_pid" "$started_at" "" +wait "$child_pid" +exit_code=$? +set -e + +result="success" +failure_reason="" +if [[ "$exit_code" -eq 0 ]]; then + result="success" +elif [[ "$exit_code" -eq 126 || "$exit_code" -eq 127 ]]; then + result="spawn_error" + failure_reason="runner_exec_failed" +elif [[ "$exit_code" -eq 130 || "$exit_code" -eq 143 ]]; then + result="interrupted" + failure_reason="signal_terminated" +else + result="failure" + failure_reason="exit_nonzero" +fi + +finished_at="$(now_iso)" +write_state "finished" "$result" "$exit_code" "$failure_reason" "$runner_pid" "$child_pid" "$started_at" "$finished_at" +exit "$exit_code" +""" + + +def _command_file_content(command: str) -> str: + return "#!/usr/bin/env bash\nset -euo pipefail\n" + command.rstrip() + "\n" + + +def _write_private_text(path: Path, data: str, mode: int) -> None: + atomic_write(path, data) + path.chmod(mode) + + +def _capture_text(session: str, *, start: int) -> str: + if not tmux_has_session(session): + return "" + capture, code = run_cmd("tmux", "capture-pane", "-t", session, "-p", "-S", str(start)) + if code != 0: + return "" + return filter_input_box(capture) + + +def _write_capture(session: str, capture: str, *, project_root: str | None = None, max_lines: int = 200) -> str: + path = session_paths(session, project_root).output + lines = capture.splitlines()[:max_lines] + _write_private_text(path, "\n".join(lines), 0o600) + return str(path) + + +def _write_full_capture(session: str, *, project_root: str | None = None) -> str: + return _write_capture(session, _capture_text(session, start=-300), project_root=project_root) + + +def _export_output_file(session: str, project_root: str) -> str: + paths = session_paths(session, project_root) + hash_value = project_hash(project_root) + return verify_or_create_output(str(paths.output), session, hash_value, project_root=project_root) + + +def _output_text(path: Path) -> str: + if not path.exists(): + return "" + try: + return read_text(path) + except OSError: + return "" + + +def _todo_counts(text: str) -> tuple[int, int]: + done = text.count("☒") + total = done + text.count("☐") + return done, total + + +def _pane_snapshot(session: str) -> PaneSnapshot: + if not tmux_has_session(session): + return PaneSnapshot(False, "", 0, False, None) + pane_id = tmux_display(session, "#{pane_id}") + pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) + pane_dead = tmux_display(session, "#{pane_dead}") == "1" + dead_status = tmux_display(session, "#{pane_dead_status}") + return PaneSnapshot(True, pane_id, pane_pid, pane_dead, _safe_int(dead_status) if dead_status else None) + + +def _load_legacy_state(path: Path) -> dict[str, str | int | bool]: + state: dict[str, str | int | bool] = { + "poll_count": 0, + "has_ever_been_active": False, + "last_todos_done": 0, + "last_todos_total": 0, + "last_statusline_time": "", + } + raw = load_session_state(path) + if not raw or raw.get("schemaVersion"): + return state + state["poll_count"] = int(raw.get("pollCount", 0) or 0) + state["has_ever_been_active"] = bool(raw.get("hasEverBeenActive", False)) + state["last_todos_done"] = int(raw.get("lastTodosDone", 0) or 0) + state["last_todos_total"] = int(raw.get("lastTodosTotal", 0) or 0) + state["last_statusline_time"] = str(raw.get("lastStatuslineTime", "") or "") + return state + + +def _save_legacy_state( + path: Path, + *, + poll_count: int, + has_active: bool, + done: int, + total: int, + status_time: str, +) -> None: + save_session_state( + path, + { + "pollCount": poll_count, + "hasEverBeenActive": has_active, + "lastTodosDone": done, + "lastTodosTotal": total, + "lastStatuslineTime": status_time, + "lastPollAt": iso_now(), + }, + ) + + +def _parse_statusline_time(capture: str) -> str: + matches = re.findall(r"\| [0-9]{2}:[0-9]{2}:[0-9]{2}", capture) + if not matches: + return "" + return matches[-1].replace("|", "").strip() + + +def _check_prompt_visible(session: str) -> str: + capture = _capture_text(session, start=-20) + lines = capture.splitlines()[-3:] + last = lines[-1].rstrip() if lines else "" + if re.search(r"❯\s*([0-9]+[smh]\s*)?[0-9]{1,2}:[0-9]{2}:[0-9]{2}\s*$", last): + return "true" + if re.search(r"(❯|\$|#|%)\s*$", last): + return "true" + return "false" + + +def _find_agent_pid(parent: str, pattern: str, depth: int) -> str: + if depth > 4: + return "" + output, code = run_cmd("pgrep", "-P", parent) + if code != 0: + return "" + for child in [line.strip() for line in output.splitlines() if line.strip()]: + command, _ = run_cmd("ps", "-o", "comm=", "-p", child) + if pattern.lower() in command.lower(): + return child + nested = _find_agent_pid(child, pattern, depth + 1) + if nested: + return nested + return "" + + +def _process_cpu(pid: int) -> float: + output, code = run_cmd("ps", "-o", "%cpu=", "-p", str(pid)) + if code != 0: + return 0.0 + try: + return float((output or "").strip() or "0") + except ValueError: + return 0.0 + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _result_from_exit_code(exit_code: int | None) -> tuple[str, str]: + if exit_code in (None, 0): + return ("success", "") + if exit_code in SIGNAL_EXIT_CODES: + return ("interrupted", "pane_dead_signal") + if exit_code in {126, 127}: + return ("spawn_error", "runner_exec_failed") + return ("failure", "pane_dead_exit") + + +def _is_terminal_state(state: dict[str, object]) -> bool: + return str(state.get("lifecycle") or "") == "finished" + + +def _state_age_seconds(state: dict[str, object]) -> float: + for key in ("updatedAt", "createdAt"): + value = str(state.get(key) or "") + if not value: + continue + parsed = _parse_iso(value) + if parsed is not None: + return max(0.0, time.time() - parsed.timestamp()) + return 0.0 + + +def _parse_iso(value: str) -> datetime | None: + text = value.strip() + if not text: + return None + try: + return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def _safe_int(value: object) -> int: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return 0 + + +def _not_found_status() -> dict[str, str | int]: + return {"status": "not_found", "todos_done": 0, "todos_total": 0, "active_task": "", "wait_estimate": 0, "session_state": "not_found"} + + +def _session_name_from_state_path(path: Path) -> str: + match = re.match(r"\.sa-[^-]+-session-(.+)-state\.json$", path.name) + return match.group(1) if match else "" diff --git a/source/tests/test_tmux_runtime.py b/source/tests/test_tmux_runtime.py new file mode 100644 index 0000000..8c64a95 --- /dev/null +++ b/source/tests/test_tmux_runtime.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import os +import stat +import tempfile +import time +import unittest +from pathlib import Path + +from story_automator.core.tmux_runtime import ( + cleanup_stale_terminal_artifacts, + command_exists, + load_session_state, + pane_status, + save_session_state, + session_paths, + session_status, + spawn_session, + tmux_kill_session, + _terminal_runner_status, +) + + +@unittest.skipUnless(command_exists("tmux"), "tmux not available") +class TmuxRuntimeIntegrationTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.project_root = self.temp_dir.name + self.sessions: list[str] = [] + + def tearDown(self) -> None: + for session in self.sessions: + tmux_kill_session(session, self.project_root) + self.temp_dir.cleanup() + + def _session_name(self, suffix: str) -> str: + session = f"sa-test-{suffix}-{int(time.time() * 1000)}" + self.sessions.append(session) + return session + + def _wait_for_terminal(self, session: str, *, codex: bool) -> dict[str, str | int]: + deadline = time.time() + 5 + last = session_status(session, full=False, codex=codex, project_root=self.project_root, mode="runner") + while time.time() < deadline: + last = session_status(session, full=False, codex=codex, project_root=self.project_root, mode="runner") + if str(last["session_state"]) in {"completed", "crashed", "stuck"}: + return last + time.sleep(0.1) + self.fail(f"session {session} did not reach terminal state, last={last}") + + def test_runner_spawn_success_records_state_and_keeps_dead_pane(self) -> None: + session = self._session_name("success") + output, code = spawn_session(session, "printf hello", "codex", self.project_root, mode="runner") + self.assertEqual((output, code), ("", 0)) + + status = self._wait_for_terminal(session, codex=True) + self.assertEqual(status["session_state"], "completed") + self.assertEqual(status["status"], "idle") + self.assertEqual(pane_status(session), "exited:0") + + paths = session_paths(session, self.project_root) + state = load_session_state(paths.state) + self.assertEqual(state["schemaVersion"], 1) + self.assertEqual(state["lifecycle"], "finished") + self.assertEqual(state["result"], "success") + self.assertEqual(state["exitCode"], 0) + self.assertEqual(stat.S_IMODE(paths.state.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(paths.command.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(paths.runner.stat().st_mode), 0o700) + + full_status = session_status(session, full=True, codex=True, project_root=self.project_root, mode="runner") + output_path = Path(str(full_status["active_task"])) + self.assertTrue(output_path.exists()) + self.assertIn("hello", output_path.read_text(encoding="utf-8")) + + def test_runner_spawn_nonzero_exit_maps_to_crashed(self) -> None: + session = self._session_name("failure") + output, code = spawn_session(session, "printf boom && exit 9", "codex", self.project_root, mode="runner") + self.assertEqual((output, code), ("", 0)) + + status = self._wait_for_terminal(session, codex=True) + self.assertEqual(status["session_state"], "crashed") + self.assertEqual(status["status"], "crashed") + self.assertEqual(status["wait_estimate"], 9) + + paths = session_paths(session, self.project_root) + state = load_session_state(paths.state) + self.assertEqual(state["result"], "failure") + self.assertEqual(state["exitCode"], 9) + + +class TmuxRuntimeStateTests(unittest.TestCase): + def test_launch_never_succeeded_maps_to_stuck(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + session = "sa-test-launch-stuck" + paths = session_paths(session, temp_dir) + save_session_state( + paths.state, + { + "schemaVersion": 1, + "session": session, + "agent": "codex", + "projectRoot": temp_dir, + "paneId": "%1", + "panePid": "", + "runnerPid": "", + "childPid": "", + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": "2026-04-13T00:00:00Z", + "startedAt": "", + "finishedAt": "2026-04-13T00:00:01Z", + "updatedAt": "2026-04-13T00:00:01Z", + "lifecycle": "finished", + "result": "unknown", + "exitCode": "", + "failureReason": "launch_never_succeeded", + }, + ) + status = _terminal_runner_status(session, load_session_state(paths.state), full=False, project_root=temp_dir) + self.assertEqual(status["session_state"], "stuck") + self.assertEqual(status["status"], "idle") + + def test_cleanup_stale_terminal_artifacts_removes_old_terminal_files(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + session = "sa-test-cleanup" + paths = session_paths(session, temp_dir) + save_session_state( + paths.state, + { + "schemaVersion": 1, + "session": session, + "agent": "codex", + "projectRoot": temp_dir, + "paneId": "%1", + "panePid": 1, + "runnerPid": 2, + "childPid": 3, + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": "2026-04-13T00:00:00Z", + "startedAt": "2026-04-13T00:00:01Z", + "finishedAt": "2026-04-13T00:00:02Z", + "updatedAt": "2026-04-13T00:00:02Z", + "lifecycle": "finished", + "result": "success", + "exitCode": 0, + "failureReason": "", + }, + ) + for path in (paths.command, paths.runner, paths.output): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x", encoding="utf-8") + stale_time = time.time() - (25 * 60 * 60) + for path in (paths.state, paths.command, paths.runner, paths.output): + os.utime(path, (stale_time, stale_time)) + + cleanup_stale_terminal_artifacts(temp_dir) + + for path in (paths.state, paths.command, paths.runner, paths.output): + self.assertFalse(path.exists(), f"expected stale artifact removal for {path}") + + +if __name__ == "__main__": + unittest.main() From ffd832eb2b62789be999595a9ccee37a4caf9899 Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 14 Apr 2026 12:08:29 -0700 Subject: [PATCH 2/6] fix: detect completed claude runner sessions --- docs/changelog/260401.md | 41 +++++++++++ .../src/story_automator/core/tmux_runtime.py | 61 +++++++++++++++-- source/tests/test_tmux_runtime.py | 68 +++++++++++++++++++ 3 files changed, 164 insertions(+), 6 deletions(-) diff --git a/docs/changelog/260401.md b/docs/changelog/260401.md index 5510c16..1aa969a 100644 --- a/docs/changelog/260401.md +++ b/docs/changelog/260401.md @@ -57,3 +57,44 @@ Added public-repo metadata, support docs, CI verification, and install smoke cov ### QA Notes - N/A + +## 260414-10:30:57 - Harden tmux runtime for non-default shells + +### Summary +Reworked the tmux execution runtime to stop depending on the tmux server's interactive shell behavior and to make session lifecycle tracking explicit and shell-independent. + +### Added +- Added a shared tmux runtime module with explicit session state, runner ownership, and tests for terminal lifecycle behavior. + +### Changed +- Replaced the older shell-dependent tmux spawning path with a runner-based runtime compatible with non-default shells and customized tmux defaults. +- Simplified `commands/tmux.py` and `adapters/tmux.py` to use the shared tmux runtime instead of duplicating lifecycle logic. +- Updated packaged tmux guidance to match the new runtime behavior. + +### Files +- `source/src/story_automator/core/tmux_runtime.py` +- `source/src/story_automator/commands/tmux.py` +- `source/src/story_automator/adapters/tmux.py` +- `source/tests/test_tmux_runtime.py` +- `payload/.claude/skills/bmad-story-automator/data/tmux-commands.md` + +### QA Notes +- `python3 -m compileall source/src` +- `PYTHONPATH=source/src python3 -m unittest source.tests.test_tmux_runtime` + +## 260414-12:07:46 - Fix Claude runner sessions that stay open at the prompt after command completion + +### Summary +Fixed a runner-mode regression where Claude command sessions could finish their work, return to the interactive prompt, and still remain marked `running`, causing `monitor-session` to hang instead of advancing the orchestrator. + +### Changed +- Updated runner-mode status detection to treat Claude sessions as complete when the pane shows the idle prompt together with a completion marker, even if the Claude process remains open interactively. +- Relaxed prompt detection so Claude's prompt is recognized when it appears above the status panel rather than only on the final captured line. + +### Files +- `source/src/story_automator/core/tmux_runtime.py` +- `source/tests/test_tmux_runtime.py` + +### QA Notes +- `PYTHONPATH=source/src python3 -m unittest source.tests.test_tmux_runtime` +- `python3 -m compileall source/src` diff --git a/source/src/story_automator/core/tmux_runtime.py b/source/src/story_automator/core/tmux_runtime.py index 49ea7db..73806a3 100644 --- a/source/src/story_automator/core/tmux_runtime.py +++ b/source/src/story_automator/core/tmux_runtime.py @@ -509,6 +509,11 @@ def _runner_session_status( todos_done, todos_total = _todo_counts(capture) active_task = extract_active_task(capture) lifecycle = str(state.get("lifecycle") or "") + prompt_visible = _check_prompt_visible(session) + + if _runner_claude_prompt_completed(paths, state, capture, prompt_visible): + state = load_session_state(paths.state) + return _terminal_runner_status(session, state, full=full, project_root=root) if lifecycle == "running" and child_alive: label = active_task or ("Codex working" if codex else "Claude working") @@ -650,6 +655,48 @@ def _reconcile_runner_state(paths: SessionPaths, state: dict[str, object], pane: return state +def _runner_claude_prompt_completed( + paths: SessionPaths, + state: dict[str, object], + capture: str, + prompt_visible: str, +) -> bool: + if str(state.get("agent") or "") != "claude": + return False + if str(state.get("lifecycle") or "") != "running": + return False + if prompt_visible != "true": + return False + if not _claude_completion_marker_present(capture): + return False + + finished = iso_now() + save_session_state( + paths.state, + { + **state, + "finishedAt": str(state.get("finishedAt") or finished), + "updatedAt": finished, + "lifecycle": "finished", + "result": "success", + "exitCode": 0, + "failureReason": "interactive_prompt_complete", + }, + ) + return True + + +def _claude_completion_marker_present(capture: str) -> bool: + if not capture: + return False + return bool( + re.search( + r"(?im)(?:\b(?:Baked|Done|Finished)\s+for\s+\d+m(?:\s+\d+s)?\b|\bfor\s+\d+m\s+\d+s\b)", + capture, + ) + ) + + def _legacy_session_status( session: str, *, @@ -1092,12 +1139,14 @@ def _parse_statusline_time(capture: str) -> str: def _check_prompt_visible(session: str) -> str: capture = _capture_text(session, start=-20) - lines = capture.splitlines()[-3:] - last = lines[-1].rstrip() if lines else "" - if re.search(r"❯\s*([0-9]+[smh]\s*)?[0-9]{1,2}:[0-9]{2}:[0-9]{2}\s*$", last): - return "true" - if re.search(r"(❯|\$|#|%)\s*$", last): - return "true" + for line in reversed(capture.splitlines()[-8:]): + current = line.rstrip() + if not current: + continue + if re.search(r"❯\s*([0-9]+[smh]\s*)?[0-9]{1,2}:[0-9]{2}:[0-9]{2}\s*$", current): + return "true" + if re.search(r"(❯|\$|#|%)\s*$", current): + return "true" return "false" diff --git a/source/tests/test_tmux_runtime.py b/source/tests/test_tmux_runtime.py index 8c64a95..7ea77fc 100644 --- a/source/tests/test_tmux_runtime.py +++ b/source/tests/test_tmux_runtime.py @@ -5,9 +5,12 @@ import tempfile import time import unittest +from unittest import mock from pathlib import Path from story_automator.core.tmux_runtime import ( + PaneSnapshot, + _check_prompt_visible, cleanup_stale_terminal_artifacts, command_exists, load_session_state, @@ -17,6 +20,7 @@ session_status, spawn_session, tmux_kill_session, + _runner_session_status, _terminal_runner_status, ) @@ -90,6 +94,70 @@ def test_runner_spawn_nonzero_exit_maps_to_crashed(self) -> None: class TmuxRuntimeStateTests(unittest.TestCase): + def test_check_prompt_visible_accepts_claude_prompt_before_status_panel(self) -> None: + capture = "\n".join( + [ + "", + "✻ Baked for 4m 55s", + "", + "────────────────────────────────────────", + "❯ ", + "────────────────────────────────────────", + " Model: Sonnet 4.6", + " Session: 4m", + " bypass permissions on", + ] + ) + with mock.patch("story_automator.core.tmux_runtime._capture_text", return_value=capture): + self.assertEqual(_check_prompt_visible("sa-test"), "true") + + def test_runner_claude_prompt_completion_maps_to_completed(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + session = "sa-test-claude-complete" + paths = session_paths(session, temp_dir) + save_session_state( + paths.state, + { + "schemaVersion": 1, + "session": session, + "agent": "claude", + "projectRoot": temp_dir, + "paneId": "%1", + "panePid": 1, + "runnerPid": 1, + "childPid": 2, + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": "2026-04-14T18:43:59Z", + "startedAt": "2026-04-14T18:43:59Z", + "finishedAt": "", + "updatedAt": "2026-04-14T18:44:00Z", + "lifecycle": "running", + "result": "", + "exitCode": "", + "failureReason": "", + }, + ) + capture = "Story created.\n\nBaked for 4m 55s\n\n❯ " + with ( + mock.patch("story_automator.core.tmux_runtime._capture_text", return_value=capture), + mock.patch("story_automator.core.tmux_runtime._check_prompt_visible", return_value="true"), + mock.patch( + "story_automator.core.tmux_runtime._pane_snapshot", + return_value=PaneSnapshot(exists=True, pane_id="%1", pane_pid=1, dead=False, dead_status=None), + ), + mock.patch("story_automator.core.tmux_runtime._pid_alive", side_effect=lambda pid: pid in {1, 2}), + ): + status = _runner_session_status(session, full=False, codex=False, project_root=temp_dir) + + self.assertEqual(status["session_state"], "completed") + self.assertEqual(status["status"], "idle") + + state = load_session_state(paths.state) + self.assertEqual(state["lifecycle"], "finished") + self.assertEqual(state["result"], "success") + self.assertEqual(state["exitCode"], 0) + def test_launch_never_succeeded_maps_to_stuck(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: session = "sa-test-launch-stuck" From 08b2c22638cec4e9bf231c00b14489c81e2763c3 Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 14 Apr 2026 19:46:08 -0700 Subject: [PATCH 3/6] fix: address tmux runtime review findings --- source/src/story_automator/adapters/tmux.py | 6 +- .../src/story_automator/core/tmux_runtime.py | 10 ++- source/tests/test_tmux_runtime.py | 62 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/source/src/story_automator/adapters/tmux.py b/source/src/story_automator/adapters/tmux.py index b18dedc..5421a01 100644 --- a/source/src/story_automator/adapters/tmux.py +++ b/source/src/story_automator/adapters/tmux.py @@ -18,6 +18,7 @@ project_slug, save_session_state, session_status, + skill_prefix, tmux_display, tmux_has_session, tmux_kill_session, @@ -36,11 +37,6 @@ class TmuxStatus: wait_estimate: int session_state: str - -def skill_prefix(agent: str) -> str: - return "none" if agent == "codex" else "bmad-" - - def tmux_new_session(session: str, root: str | Path, selected_agent: str) -> tuple[str, int]: return run_cmd( "tmux", diff --git a/source/src/story_automator/core/tmux_runtime.py b/source/src/story_automator/core/tmux_runtime.py index 73806a3..ba48e87 100644 --- a/source/src/story_automator/core/tmux_runtime.py +++ b/source/src/story_automator/core/tmux_runtime.py @@ -76,7 +76,7 @@ def agent_cli(agent: str) -> str: def skill_prefix(agent: str) -> str: - return "none" if agent == "codex" else "/bmad-bmm-" + return "none" if agent == "codex" else "bmad-" def session_paths(session: str, project_root: str | None = None) -> SessionPaths: @@ -138,7 +138,7 @@ def update_session_state(path: str | Path, **updates: object) -> dict[str, objec target = Path(path) state = load_session_state(target) state.update(updates) - state["updatedAt"] = str(state.get("updatedAt") or iso_now()) + state["updatedAt"] = iso_now() save_session_state(target, state) return state @@ -231,7 +231,7 @@ def heartbeat_check( cpu = _process_cpu(child_pid) if _pid_alive(child_pid): - return (("alive" if int(cpu) > 0 else "idle"), cpu, str(child_pid), prompt) + return (("alive" if cpu > 0.1 else "idle"), cpu, str(child_pid), prompt) time.sleep(RECONCILE_GRACE_SECONDS) status = session_status(session, full=False, codex=selected_agent == "codex", project_root=project_root, mode=resolved_mode) @@ -1181,6 +1181,10 @@ def _pid_alive(pid: int) -> bool: return False try: os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True except OSError: return False return True diff --git a/source/tests/test_tmux_runtime.py b/source/tests/test_tmux_runtime.py index 7ea77fc..88a9fc2 100644 --- a/source/tests/test_tmux_runtime.py +++ b/source/tests/test_tmux_runtime.py @@ -13,8 +13,10 @@ _check_prompt_visible, cleanup_stale_terminal_artifacts, command_exists, + heartbeat_check, load_session_state, pane_status, + skill_prefix, save_session_state, session_paths, session_status, @@ -22,6 +24,7 @@ tmux_kill_session, _runner_session_status, _terminal_runner_status, + update_session_state, ) @@ -94,6 +97,25 @@ def test_runner_spawn_nonzero_exit_maps_to_crashed(self) -> None: class TmuxRuntimeStateTests(unittest.TestCase): + def test_skill_prefix_matches_pure_skill_layout(self) -> None: + self.assertEqual(skill_prefix("claude"), "bmad-") + self.assertEqual(skill_prefix("codex"), "none") + + def test_update_session_state_refreshes_updated_at(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + state_path = Path(temp_dir) / "state.json" + save_session_state( + state_path, + { + "updatedAt": "2026-04-14T18:44:00Z", + "lifecycle": "created", + }, + ) + with mock.patch("story_automator.core.tmux_runtime.iso_now", return_value="2026-04-14T18:45:00Z"): + state = update_session_state(state_path, lifecycle="running") + self.assertEqual(state["updatedAt"], "2026-04-14T18:45:00Z") + self.assertEqual(load_session_state(state_path)["updatedAt"], "2026-04-14T18:45:00Z") + def test_check_prompt_visible_accepts_claude_prompt_before_status_panel(self) -> None: capture = "\n".join( [ @@ -228,6 +250,46 @@ def test_cleanup_stale_terminal_artifacts_removes_old_terminal_files(self) -> No for path in (paths.state, paths.command, paths.runner, paths.output): self.assertFalse(path.exists(), f"expected stale artifact removal for {path}") + def test_pane_status_treats_fractional_cpu_as_alive(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + session = "sa-test-fractional-cpu" + paths = session_paths(session, temp_dir) + save_session_state( + paths.state, + { + "schemaVersion": 1, + "session": session, + "agent": "codex", + "projectRoot": temp_dir, + "paneId": "%1", + "panePid": 10, + "runnerPid": 11, + "childPid": 12, + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": "2026-04-14T18:43:59Z", + "startedAt": "2026-04-14T18:43:59Z", + "finishedAt": "", + "updatedAt": "2026-04-14T18:44:00Z", + "lifecycle": "running", + "result": "", + "exitCode": "", + "failureReason": "", + }, + ) + with ( + mock.patch("story_automator.core.tmux_runtime._process_cpu", return_value=0.5), + mock.patch("story_automator.core.tmux_runtime._pid_alive", return_value=True), + mock.patch("story_automator.core.tmux_runtime._check_prompt_visible", return_value="false"), + mock.patch("story_automator.core.tmux_runtime.tmux_has_session", return_value=True), + ): + status, cpu, pid, prompt = heartbeat_check(session, "codex", project_root=temp_dir, mode="runner") + + self.assertEqual(status, "alive") + self.assertEqual(cpu, 0.5) + self.assertEqual(pid, "12") + self.assertEqual(prompt, "false") + if __name__ == "__main__": unittest.main() From 82a83eccbfb4f983a6f898760b93f7e323836576 Mon Sep 17 00:00:00 2001 From: bmad <236206860+bma-d@users.noreply.github.com> Date: Thu, 16 Apr 2026 03:51:52 -0300 Subject: [PATCH 4/6] fix: preserve tmux payload shell semantics --- .../src/story_automator/core/tmux_runtime.py | 40 +++++++++++++++++-- source/tests/test_tmux_runtime.py | 18 +++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/source/src/story_automator/core/tmux_runtime.py b/source/src/story_automator/core/tmux_runtime.py index ba48e87..ab07ff2 100644 --- a/source/src/story_automator/core/tmux_runtime.py +++ b/source/src/story_automator/core/tmux_runtime.py @@ -58,6 +58,14 @@ def runtime_mode() -> str: return value if value in VALID_RUNTIME_MODES else "auto" +def resolve_command_shell() -> str: + for candidate in (_tmux_default_shell(), os.environ.get("SHELL", "").strip(), shutil.which("bash") or ""): + resolved = _resolve_shell_path(candidate) + if resolved: + return resolved + return "/bin/sh" + + def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") -> str: stamp = time.strftime("%y%m%d-%H%M%S", time.localtime()) suffix = story_id.replace(".", "-") @@ -334,6 +342,7 @@ def _spawn_runner(session: str, command: str, selected_agent: str, project_root: bash_path = shutil.which("bash") if not bash_path: return ("bash not found\n", 1) + command_shell = resolve_command_shell() root = Path(project_root or get_project_root()).resolve() cleanup_stale_terminal_artifacts(str(root)) @@ -341,7 +350,7 @@ def _spawn_runner(session: str, command: str, selected_agent: str, project_root: cleanup_runtime_artifacts(session, str(root)) _write_private_text(paths.command, _command_file_content(command), 0o700) - _write_private_text(paths.runner, _runner_file_content(paths, bash_path, str(root)), 0o700) + _write_private_text(paths.runner, _runner_file_content(paths, bash_path, command_shell, str(root)), 0o700) create_out, create_code = run_cmd( "tmux", @@ -901,9 +910,10 @@ def _resolve_spawn_mode(mode: str | None) -> str: return "runner" -def _runner_file_content(paths: SessionPaths, bash_path: str, project_root: str) -> str: +def _runner_file_content(paths: SessionPaths, bash_path: str, command_shell: str, project_root: str) -> str: state_file = shlex.quote(str(paths.state)) command_file = shlex.quote(str(paths.command)) + resolved_command_shell = shlex.quote(command_shell) root = shlex.quote(project_root) python_bin = shlex.quote(sys.executable) return f"""#!/usr/bin/env bash @@ -913,6 +923,7 @@ def _runner_file_content(paths: SessionPaths, bash_path: str, project_root: str) STATE_FILE={state_file} COMMAND_FILE={command_file} +COMMAND_SHELL={resolved_command_shell} PYTHON_BIN={python_bin} write_state() {{ @@ -999,8 +1010,12 @@ def now_iso() -> str: exit 127 fi +run_payload() {{ + "$COMMAND_SHELL" "$COMMAND_FILE" +}} + set +e -"{bash_path}" "$COMMAND_FILE" & +run_payload & child_pid=$! started_at="$(now_iso)" write_state "running" "" "" "" "$runner_pid" "$child_pid" "$started_at" "" @@ -1030,7 +1045,7 @@ def now_iso() -> str: def _command_file_content(command: str) -> str: - return "#!/usr/bin/env bash\nset -euo pipefail\n" + command.rstrip() + "\n" + return command.rstrip() + "\n" def _write_private_text(path: Path, data: str, mode: int) -> None: @@ -1038,6 +1053,23 @@ def _write_private_text(path: Path, data: str, mode: int) -> None: path.chmod(mode) +def _tmux_default_shell() -> str: + if not command_exists("tmux"): + return "" + output, code = run_cmd("tmux", "show-options", "-gv", "default-shell") + if code != 0: + return "" + return output.strip() + + +def _resolve_shell_path(candidate: str) -> str: + if not candidate: + return "" + if os.path.isabs(candidate): + return candidate if os.path.isfile(candidate) and os.access(candidate, os.X_OK) else "" + return shutil.which(candidate) or "" + + def _capture_text(session: str, *, start: int) -> str: if not tmux_has_session(session): return "" diff --git a/source/tests/test_tmux_runtime.py b/source/tests/test_tmux_runtime.py index 88a9fc2..d86f131 100644 --- a/source/tests/test_tmux_runtime.py +++ b/source/tests/test_tmux_runtime.py @@ -11,11 +11,13 @@ from story_automator.core.tmux_runtime import ( PaneSnapshot, _check_prompt_visible, + _runner_file_content, cleanup_stale_terminal_artifacts, command_exists, heartbeat_check, load_session_state, pane_status, + resolve_command_shell, skill_prefix, save_session_state, session_paths, @@ -101,6 +103,22 @@ def test_skill_prefix_matches_pure_skill_layout(self) -> None: self.assertEqual(skill_prefix("claude"), "bmad-") self.assertEqual(skill_prefix("codex"), "none") + def test_resolve_command_shell_prefers_tmux_default_shell(self) -> None: + with ( + mock.patch("story_automator.core.tmux_runtime.command_exists", return_value=True), + mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("/bin/zsh\n", 0)), + mock.patch("story_automator.core.tmux_runtime.os.path.isfile", return_value=True), + mock.patch("story_automator.core.tmux_runtime.os.access", return_value=True), + ): + self.assertEqual(resolve_command_shell(), "/bin/zsh") + + def test_runner_file_content_uses_interactive_shell_for_zsh_payloads(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + paths = session_paths("sa-test-shell", temp_dir) + content = _runner_file_content(paths, "/bin/bash", "/bin/zsh", temp_dir) + self.assertIn('COMMAND_SHELL=/bin/zsh', content) + self.assertIn('"$COMMAND_SHELL" "$COMMAND_FILE"', content) + def test_update_session_state_refreshes_updated_at(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: state_path = Path(temp_dir) / "state.json" From 99b2c959f7bed6747020082b73a773e199732145 Mon Sep 17 00:00:00 2001 From: bmad <236206860+bma-d@users.noreply.github.com> Date: Fri, 17 Apr 2026 04:08:02 -0300 Subject: [PATCH 5/6] fix: address tmux runtime review findings --- .../src/story_automator/core/tmux_runtime.py | 59 +++++++++++++---- source/tests/test_tmux_runtime.py | 65 ++++++++++++++++++- 2 files changed, 111 insertions(+), 13 deletions(-) diff --git a/source/src/story_automator/core/tmux_runtime.py b/source/src/story_automator/core/tmux_runtime.py index ab07ff2..6ce4e28 100644 --- a/source/src/story_automator/core/tmux_runtime.py +++ b/source/src/story_automator/core/tmux_runtime.py @@ -34,6 +34,7 @@ RUNNER_MODE_ENV = "SA_TMUX_RUNTIME" VALID_RUNTIME_MODES = {"legacy", "runner", "auto"} SIGNAL_EXIT_CODES = {130, 131, 143} +SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,160}$") @dataclass(frozen=True) @@ -88,6 +89,7 @@ def skill_prefix(agent: str) -> str: def session_paths(session: str, project_root: str | None = None) -> SessionPaths: + session = _validated_session_name(session) hash_value = project_hash(project_root) return SessionPaths( state=Path(f"/tmp/.sa-{hash_value}-session-{session}-state.json"), @@ -163,18 +165,21 @@ def cleanup_stale_terminal_artifacts(project_root: str | None = None, ttl_second root_hash = project_hash(project_root) cutoff = time.time() - ttl_seconds tmp_dir = Path("/tmp") + protected_sessions: set[str] = set() state_paths = tmp_dir.glob(f".sa-{root_hash}-session-*-state.json") for state_path in state_paths: + session = _session_name_from_state_path(state_path) + if not session: + state_path.unlink(missing_ok=True) + continue + state = load_session_state(state_path) + if not _is_terminal_state(state): + protected_sessions.add(session) try: if state_path.stat().st_mtime > cutoff: continue except OSError: continue - state = load_session_state(state_path) - session = _session_name_from_state_path(state_path) - if not session: - state_path.unlink(missing_ok=True) - continue if _is_terminal_state(state): cleanup_runtime_artifacts(session, project_root) for pattern in ( @@ -184,10 +189,14 @@ def cleanup_stale_terminal_artifacts(project_root: str | None = None, ttl_second ): for path in tmp_dir.glob(pattern): try: - if path.stat().st_mtime <= cutoff: - path.unlink(missing_ok=True) + if path.stat().st_mtime > cutoff: + continue except OSError: continue + session = _session_name_from_artifact_path(path, root_hash) + if session and session in protected_sessions: + continue + path.unlink(missing_ok=True) def tmux_kill_session(session: str, project_root: str | None = None) -> None: @@ -433,7 +442,6 @@ def _spawn_runner(session: str, command: str, selected_agent: str, project_root: paths.state, paneId=pane_id, panePid=respawned_pane_pid, - updatedAt=iso_now(), ) return ("", 0) @@ -689,7 +697,7 @@ def _runner_claude_prompt_completed( "lifecycle": "finished", "result": "success", "exitCode": 0, - "failureReason": "interactive_prompt_complete", + "failureReason": "", }, ) return True @@ -885,7 +893,7 @@ def _legacy_heartbeat_check(session: str, selected_agent: str) -> tuple[str, flo if not agent_pid: return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) cpu = _process_cpu(int(agent_pid)) - status = "alive" if int(cpu) > 0 else "idle" + status = "alive" if cpu > 0.1 else "idle" if prompt == "true": status = "completed" return (status, cpu, agent_pid, prompt) @@ -1030,7 +1038,7 @@ def now_iso() -> str: elif [[ "$exit_code" -eq 126 || "$exit_code" -eq 127 ]]; then result="spawn_error" failure_reason="runner_exec_failed" -elif [[ "$exit_code" -eq 130 || "$exit_code" -eq 143 ]]; then +elif [[ "$exit_code" -eq 130 || "$exit_code" -eq 131 || "$exit_code" -eq 143 ]]; then result="interrupted" failure_reason="signal_terminated" else @@ -1268,6 +1276,33 @@ def _not_found_status() -> dict[str, str | int]: return {"status": "not_found", "todos_done": 0, "todos_total": 0, "active_task": "", "wait_estimate": 0, "session_state": "not_found"} +def _validated_session_name(session: str) -> str: + if not SESSION_NAME_RE.fullmatch(session): + raise ValueError(f"invalid session name: {session!r}") + return session + + def _session_name_from_state_path(path: Path) -> str: match = re.match(r"\.sa-[^-]+-session-(.+)-state\.json$", path.name) - return match.group(1) if match else "" + if not match: + return "" + try: + return _validated_session_name(match.group(1)) + except ValueError: + return "" + + +def _session_name_from_artifact_path(path: Path, root_hash: str) -> str: + patterns = ( + rf"\.sa-{re.escape(root_hash)}-session-(.+)-(?:command|runner)\.sh$", + rf"sa-{re.escape(root_hash)}-output-(.+)\.txt$", + ) + for pattern in patterns: + match = re.match(pattern, path.name) + if not match: + continue + try: + return _validated_session_name(match.group(1)) + except ValueError: + return "" + return "" diff --git a/source/tests/test_tmux_runtime.py b/source/tests/test_tmux_runtime.py index d86f131..70188c0 100644 --- a/source/tests/test_tmux_runtime.py +++ b/source/tests/test_tmux_runtime.py @@ -11,6 +11,7 @@ from story_automator.core.tmux_runtime import ( PaneSnapshot, _check_prompt_visible, + _legacy_heartbeat_check, _runner_file_content, cleanup_stale_terminal_artifacts, command_exists, @@ -48,7 +49,8 @@ def _session_name(self, suffix: str) -> str: return session def _wait_for_terminal(self, session: str, *, codex: bool) -> dict[str, str | int]: - deadline = time.time() + 5 + timeout_seconds = float(os.environ.get("TMUX_TEST_TIMEOUT_SECONDS", "15")) + deadline = time.time() + timeout_seconds last = session_status(session, full=False, codex=codex, project_root=self.project_root, mode="runner") while time.time() < deadline: last = session_status(session, full=False, codex=codex, project_root=self.project_root, mode="runner") @@ -118,6 +120,12 @@ def test_runner_file_content_uses_interactive_shell_for_zsh_payloads(self) -> No content = _runner_file_content(paths, "/bin/bash", "/bin/zsh", temp_dir) self.assertIn('COMMAND_SHELL=/bin/zsh', content) self.assertIn('"$COMMAND_SHELL" "$COMMAND_FILE"', content) + self.assertIn('"$exit_code" -eq 131', content) + + def test_session_paths_rejects_unsafe_session_names(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + with self.assertRaises(ValueError): + session_paths("../escape", temp_dir) def test_update_session_state_refreshes_updated_at(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -197,6 +205,7 @@ def test_runner_claude_prompt_completion_maps_to_completed(self) -> None: self.assertEqual(state["lifecycle"], "finished") self.assertEqual(state["result"], "success") self.assertEqual(state["exitCode"], 0) + self.assertEqual(state["failureReason"], "") def test_launch_never_succeeded_maps_to_stuck(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -268,6 +277,45 @@ def test_cleanup_stale_terminal_artifacts_removes_old_terminal_files(self) -> No for path in (paths.state, paths.command, paths.runner, paths.output): self.assertFalse(path.exists(), f"expected stale artifact removal for {path}") + def test_cleanup_stale_terminal_artifacts_keeps_old_running_files(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + session = "sa-test-active-cleanup" + paths = session_paths(session, temp_dir) + save_session_state( + paths.state, + { + "schemaVersion": 1, + "session": session, + "agent": "codex", + "projectRoot": temp_dir, + "paneId": "%1", + "panePid": 1, + "runnerPid": 2, + "childPid": 3, + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": "2026-04-13T00:00:00Z", + "startedAt": "2026-04-13T00:00:01Z", + "finishedAt": "", + "updatedAt": "2026-04-13T00:00:02Z", + "lifecycle": "running", + "result": "", + "exitCode": "", + "failureReason": "", + }, + ) + for path in (paths.command, paths.runner, paths.output): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x", encoding="utf-8") + stale_time = time.time() - (25 * 60 * 60) + for path in (paths.state, paths.command, paths.runner, paths.output): + os.utime(path, (stale_time, stale_time)) + + cleanup_stale_terminal_artifacts(temp_dir) + + for path in (paths.state, paths.command, paths.runner, paths.output): + self.assertTrue(path.exists(), f"expected active artifact preservation for {path}") + def test_pane_status_treats_fractional_cpu_as_alive(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: session = "sa-test-fractional-cpu" @@ -308,6 +356,21 @@ def test_pane_status_treats_fractional_cpu_as_alive(self) -> None: self.assertEqual(pid, "12") self.assertEqual(prompt, "false") + def test_legacy_heartbeat_treats_fractional_cpu_as_alive(self) -> None: + with ( + mock.patch("story_automator.core.tmux_runtime.tmux_has_session", return_value=True), + mock.patch("story_automator.core.tmux_runtime._capture_text", return_value="working"), + mock.patch("story_automator.core.tmux_runtime.tmux_display", return_value="10"), + mock.patch("story_automator.core.tmux_runtime._find_agent_pid", return_value="12"), + mock.patch("story_automator.core.tmux_runtime._process_cpu", return_value=0.5), + ): + status, cpu, pid, prompt = _legacy_heartbeat_check("sa-test-legacy-cpu", "codex") + + self.assertEqual(status, "alive") + self.assertEqual(cpu, 0.5) + self.assertEqual(pid, "12") + self.assertEqual(prompt, "false") + if __name__ == "__main__": unittest.main() From 948c3107c9a35f0854b1bcbc8265fca889f49f5a Mon Sep 17 00:00:00 2001 From: bmad <236206860+bma-d@users.noreply.github.com> Date: Fri, 17 Apr 2026 04:38:56 -0300 Subject: [PATCH 6/6] fix: tighten tmux runner completion handling --- .../src/story_automator/core/tmux_runtime.py | 8 ++-- source/tests/test_tmux_runtime.py | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/source/src/story_automator/core/tmux_runtime.py b/source/src/story_automator/core/tmux_runtime.py index 6ce4e28..2c57fbf 100644 --- a/source/src/story_automator/core/tmux_runtime.py +++ b/source/src/story_automator/core/tmux_runtime.py @@ -632,7 +632,7 @@ def _reconcile_runner_state(paths: SessionPaths, state: dict[str, object], pane: "updatedAt": finished, "lifecycle": "finished", "result": result, - "exitCode": pane_snapshot.dead_status if pane_snapshot.dead_status is not None else 0, + "exitCode": pane_snapshot.dead_status if pane_snapshot.dead_status is not None else "", "failureReason": reason, }, ) @@ -708,7 +708,7 @@ def _claude_completion_marker_present(capture: str) -> bool: return False return bool( re.search( - r"(?im)(?:\b(?:Baked|Done|Finished)\s+for\s+\d+m(?:\s+\d+s)?\b|\bfor\s+\d+m\s+\d+s\b)", + r"(?im)\b(?:Baked|Done|Finished)\s+for\s+\d+m(?:\s+\d+s)?\b", capture, ) ) @@ -1231,8 +1231,10 @@ def _pid_alive(pid: int) -> bool: def _result_from_exit_code(exit_code: int | None) -> tuple[str, str]: - if exit_code in (None, 0): + if exit_code == 0: return ("success", "") + if exit_code is None: + return ("unknown", "pane_dead_unknown_status") if exit_code in SIGNAL_EXIT_CODES: return ("interrupted", "pane_dead_signal") if exit_code in {126, 127}: diff --git a/source/tests/test_tmux_runtime.py b/source/tests/test_tmux_runtime.py index 70188c0..fc786b4 100644 --- a/source/tests/test_tmux_runtime.py +++ b/source/tests/test_tmux_runtime.py @@ -11,7 +11,9 @@ from story_automator.core.tmux_runtime import ( PaneSnapshot, _check_prompt_visible, + _claude_completion_marker_present, _legacy_heartbeat_check, + _reconcile_runner_state, _runner_file_content, cleanup_stale_terminal_artifacts, command_exists, @@ -207,6 +209,49 @@ def test_runner_claude_prompt_completion_maps_to_completed(self) -> None: self.assertEqual(state["exitCode"], 0) self.assertEqual(state["failureReason"], "") + def test_claude_completion_marker_ignores_generic_duration_text(self) -> None: + capture = "all tests passed for 3m 10s\n\n❯ " + self.assertFalse(_claude_completion_marker_present(capture)) + + def test_reconcile_dead_pane_without_status_maps_to_unknown(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + session = "sa-test-pane-dead-unknown" + paths = session_paths(session, temp_dir) + save_session_state( + paths.state, + { + "schemaVersion": 1, + "session": session, + "agent": "codex", + "projectRoot": temp_dir, + "paneId": "%1", + "panePid": 1, + "runnerPid": 1, + "childPid": 2, + "commandFile": str(paths.command), + "outputHint": str(paths.output), + "createdAt": "2026-04-14T18:43:59Z", + "startedAt": "2026-04-14T18:43:59Z", + "finishedAt": "", + "updatedAt": "2026-04-14T18:44:00Z", + "lifecycle": "running", + "result": "", + "exitCode": "", + "failureReason": "", + }, + ) + + reconciled = _reconcile_runner_state( + paths, + load_session_state(paths.state), + PaneSnapshot(exists=True, pane_id="%1", pane_pid=1, dead=True, dead_status=None), + ) + + self.assertEqual(reconciled["lifecycle"], "finished") + self.assertEqual(reconciled["result"], "unknown") + self.assertEqual(reconciled["exitCode"], "") + self.assertEqual(reconciled["failureReason"], "pane_dead_unknown_status") + def test_launch_never_succeeded_maps_to_stuck(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: session = "sa-test-launch-stuck"