Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/cli_agent_orchestrator/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,19 @@ async def lifespan(app: FastAPI):
inbox_service_task = asyncio.create_task(inbox_service.run(registry))
logger.info("Event bus consumers started (StatusMonitor, LogWriter, InboxService)")

# Tmux panes intentionally survive API-process restarts. Recreate their
# FIFO readers and seed status before inbox reconciliation begins, otherwise
# pending worker callbacks can remain stranded behind UNKNOWN forever.
from cli_agent_orchestrator.services.terminal_service import (
recover_persisted_terminal_output_streams,
)

await asyncio.to_thread(recover_persisted_terminal_output_streams)
# Recovery publishes initial status before the consumer tasks get their first
# scheduling turn. Reconcile once here so a callback already pending at
# restart is not left waiting for the periodic sweep.
await asyncio.to_thread(inbox_service.reconcile_orphaned_messages, registry)

# Start ApprovalBridge when AG-UI surface is enabled
approval_bridge_task: Optional[asyncio.Task] = None
from cli_agent_orchestrator.services.agui_enablement import agui_surface_enabled
Expand Down
21 changes: 21 additions & 0 deletions src/cli_agent_orchestrator/services/status_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,27 @@ def clear_rolling_buffer(self, terminal_id: str) -> None:
with self._lock:
self._buffers[terminal_id] = ""

def seed_from_snapshot(self, terminal_id: str, output: str) -> TerminalStatus:
"""Prime status from a rendered pane snapshot after server recovery.

A tmux pane survives a ``cao-server`` restart, but its old pipe-pane
target points at the previous process's FIFO reader. Reattaching the
pipe only observes *future* output, so a terminal already waiting at an
input prompt would otherwise remain UNKNOWN indefinitely. Seed the
rolling buffer from ``capture-pane`` once, then run the ordinary
provider detector and publish the resulting state. This is deliberately
not an input action: it never writes to the terminal.
"""
state_buffer_max = get_server_settings()["state_buffer_max"]
with self._lock:
self._buffers[terminal_id] = output[-state_buffer_max:]
# A persisted terminal is quiescent at recovery time. Do not let a
# prior process's debounce state suppress its first real status.
self._bursting[terminal_id] = False
detected = self._detect_status(terminal_id, output[-state_buffer_max:])
Comment on lines +506 to +510

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Seeding bypasses the pyte screen, so a worker that was mid-turn at restart stays PROCESSING forever

seed_from_snapshot writes the snapshot into self._buffers and calls _detect_status — the raw path. It never touches self._screens. But _process_chunk:139-142 routes every provider with supports_screen_detection = True through the pyte screen whenever CAO_PYTE_STATUS is on, and that is the default (constants.py:228). Five providers opt in, including claude_code.

So after recovery the pyte screen is blank. The next real chunk off the re-armed FIFO is composited onto nothing, and TUI output is overwhelmingly cursor-addressed in-place repainting — which is the entire reason the screen path exists.

Reproduced at d9597de (pane mid-turn at restart; Ink then repaints row 2 in place to finish the turn):

A) PR #597  status_monitor.seed_from_snapshot()
   screen primed at recovery : False
   viewport after repaint    : ['✻ Crunched for 12s']
   seeded -> final status    : processing -> processing
   InboxService delivers?    : False

B) replay through _process_chunk (the existing primitive)
   screen primed at recovery : True
   viewport after repaint    : ['● Working on the task', '✻ Crunched for 12s',
                                '──────────', '❯', '──────────']
   seeded -> final status    : processing -> completed
   InboxService delivers?    : True

In (A) the truncated viewport yields UNKNOWN, which _apply_detection correctly suppresses because a known status is already latched — so the terminal is pinned at PROCESSING. An idle agent emits nothing further, so nothing ever re-triggers detection. The pending callback is never delivered, and only another restart clears it. That is precisely the failure this PR set out to fix; it just moved from "was idle at restart" to "was busy at restart", which is the more likely restart scenario.

The codebase already solved this. fifo_reader._rearm_stalled_pipe:686-688 recovers a dead forwarder by replaying the pane through the normal pipeline, and its docstring even documents the \r\n requirement:

replay = content.replace("\n", "\r\n")
bus.publish(f"terminal.{terminal_id}.output", {"data": replay})

That \r\n is not cosmetic — get_history joins with a bare \n and pyte defaults LNM off, so raw capture-pane output staircases:

bare \n:                        \r\n:
|● Working                      |● Working
|         ✻ Cultivating… (12s)  |✻ Cultivating… (12s)
|                     ────────  |────────

Please reuse that primitive instead of adding a parallel one. Keeping it synchronous (which you need, so the seed lands before the reconcile_orphaned_messages call at api/main.py:1102) is easy — _schedule_screen_detection detects on the rising edge inline when _bursting is False, which you already set:

        with self._lock:
            self._buffers[terminal_id] = ""
            self._screens.pop(terminal_id, None)
            self._bursting[terminal_id] = False
        # One code path for both detectors: primes the pyte screen, fills the
        # raw buffer, and picks the capability-correct detector.
        self._process_chunk(terminal_id, output.replace("\n", "\r\n"))
        return self.get_status(terminal_id)

And take the snapshot after _rearm_pipe() in terminal_service.py (Copilot's point at :240) so output produced during reattachment is included rather than lost to the old FIFO.

Whichever shape you land on, this needs a test that actually runs the real StatusMonitor — see my note on the test file.

self._apply_detection(terminal_id, detected)
return detected

def _detect_status(self, terminal_id: str, buffer: str) -> TerminalStatus:
"""Detect status: provider-specific patterns or UNKNOWN if no provider."""
provider = provider_manager.get_provider(terminal_id)
Expand Down
80 changes: 80 additions & 0 deletions src/cli_agent_orchestrator/services/terminal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from cli_agent_orchestrator.clients.database import delete_terminal as db_delete_terminal
from cli_agent_orchestrator.clients.database import (
get_terminal_metadata,
list_terminals_by_session,
list_siblings_by_group_prefix,
update_last_active,
update_terminal_group,
Expand Down Expand Up @@ -169,6 +170,85 @@ class OutputMode(str, Enum):
}


def recover_persisted_terminal_output_streams() -> int:
"""Reattach FIFO/status monitoring to tmux terminals surviving a restart.

Terminal rows and tmux panes intentionally outlive the CAO API process. On
restart, however, the old process's FIFO readers disappear and tmux keeps
forwarding to their now-unread FIFO paths. Re-arm each live, persisted
pane, then seed status from a read-only rendered snapshot so InboxService
can deliver messages already waiting for an idle supervisor.

Returns the number of panes successfully recovered. Missing/stale database
rows are expected and skipped; this function must never prevent server
startup or send input to an existing agent.
"""
backend = get_backend()
if backend.supports_event_inbox():
return 0

recovered = 0
try:
sessions = backend.list_sessions()
except Exception as exc:
logger.warning("Cannot enumerate persisted terminal streams at startup: %s", exc)
return 0

for session in sessions:
session_name = session.get("id")
if not session_name:
continue
try:
terminals = list_terminals_by_session(session_name)
except Exception as exc:
logger.warning("Cannot list persisted terminals for %s: %s", session_name, exc)
continue

for terminal in terminals:
terminal_id = terminal["id"]
window_name = terminal["tmux_window"]
try:
# This doubles as a non-mutating liveness check for a stale DB
# row whose tmux window was already removed.
snapshot = backend.get_history(
session_name, window_name, tail_lines=PIPE_LIVENESS_TAIL_LINES
)
Comment on lines +213 to +215
except Exception as exc:
logger.info(
"Skipping persisted terminal %s: pane %s:%s is unavailable (%s)",
terminal_id,
session_name,
window_name,
exc,
)
continue

fifo_path = FIFO_DIR / f"{terminal_id}.fifo"

def _probe_pane(s=session_name, w=window_name) -> str:
return get_backend().get_history(s, w, tail_lines=PIPE_LIVENESS_TAIL_LINES)

def _rearm_pipe(s=session_name, w=window_name, p=str(fifo_path)) -> None:
get_backend().stop_pipe_pane(s, w)
get_backend().pipe_pane(s, w, p)

try:
fifo_manager.create_reader(terminal_id, pane_probe=_probe_pane, rearm=_rearm_pipe)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Recovered idle terminals trip the cold-start watchdog and get permanently dropped from it

create_reader unconditionally sets _ever_delivered[terminal_id] = False and _registered_at[terminal_id] = now (fifo_reader.py:180-182). For a newly launched terminal that is a correct assumption — a starting CLI always emits something. For a recovered idle terminal it is wrong: the pane is quiescent by definition, so the FIFO will never deliver a byte.

The cold-start branch at fifo_reader.py:490-497 then matches on exactly that shape — not ever_delivered and past the grace window and content.strip() (a recovered pane is full of prior conversation) — and concludes "the forwarder never started, full stop." Note the self-ROAST comment at :498-508: the replay publishes straight to the bus and never flips _ever_delivered, so each attempt re-qualifies.

Reproduced at d9597de with the exact probe/rearm pair this function registers and an idle pane:

grace=3.0s  max_cold_start_attempts=5
after create_reader: _ever_delivered=False  enrolled=True
  tick 1: rearms=1  still_enrolled=True
  ...
  tick 5: rearms=5  still_enrolled=True
  tick 6: rearms=5  still_enrolled=False

ERROR pipe-pane forwarder for terminal t1 never started delivering after 5
      cold-start re-arm attempts — giving up and dropping it from the
      liveness watchdog

So every recovered idle supervisor — the exact population this PR targets — gets ~5 spurious stop_pipe_pane/pipe_pane cycles plus 5 full-pane replays in the first ~20s (PIPE_LIVENESS_CHECK_INTERVAL_S 4.0s), an ERROR log, and is then unenrolled from the issue #388 liveness watchdog for the life of the process. If its pipe genuinely stalls later, nothing repairs it — silently, since the give-up already logged and won't log again.

P2 rather than P1 because the immediate effect is loud and benign-to-helpful; what's lost is a safety net, quietly.

Recovery knows the pipe was just re-armed against a live pane, so the cold-start check is not applicable here. Give create_reader a way to say so and have recovery pass it — the ordinary divergence check still protects these terminals:

# fifo_reader.create_reader(..., assume_delivered: bool = False)
self._ever_delivered[terminal_id] = assume_delivered

# terminal_service, recovery path
fifo_manager.create_reader(
    terminal_id, pane_probe=_probe_pane, rearm=_rearm_pipe, assume_delivered=True
)

# pipe-pane has one destination. Stop first: ``pipe-pane -o``
# would otherwise toggle an inherited stale target off.
_rearm_pipe()
status_monitor.seed_from_snapshot(terminal_id, snapshot)
Comment on lines +239 to +240
recovered += 1
except Exception as exc:
# The terminal itself is untouched; a later restart or manual
# send can retry recovery. Leave the process up for healthy panes.
logger.warning("Failed to recover output stream for %s: %s", terminal_id, exc)

if recovered:
logger.info("Recovered output/status streams for %d persisted terminal(s)", recovered)
return recovered


async def create_terminal(
provider: str,
agent_profile: str,
Expand Down
48 changes: 48 additions & 0 deletions test/services/test_terminal_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Recovery of tmux output streams after a CAO API-process restart."""

from unittest.mock import MagicMock, patch

from cli_agent_orchestrator.models.terminal import TerminalStatus


@patch("cli_agent_orchestrator.services.terminal_service.status_monitor")
@patch("cli_agent_orchestrator.services.terminal_service.fifo_manager")
@patch("cli_agent_orchestrator.services.terminal_service.list_terminals_by_session")
@patch("cli_agent_orchestrator.services.terminal_service.get_backend")
def test_recover_persisted_tmux_terminal_rearms_pipe_and_seeds_status(
backend_getter, list_terminals, fifo_manager, status_monitor
):
from cli_agent_orchestrator.services.terminal_service import (
recover_persisted_terminal_output_streams,
)

backend = MagicMock()
backend.supports_event_inbox.return_value = False
backend.list_sessions.return_value = [{"id": "cao-live"}]
backend.get_history.return_value = "❯ ready"
backend_getter.return_value = backend
list_terminals.return_value = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] black fails here (CI red), and mocking status_monitor leaves the new logic completely untested

The Code Quality job fails on this file. Reproduced locally with uv run black --check:

-    list_terminals.return_value = [
-        {"id": "abc12345", "tmux_window": "tech_lead-a1b2"}
-    ]
+    list_terminals.return_value = [{"id": "abc12345", "tmux_window": "tech_lead-a1b2"}]

Separately — and this is why the P1 shipped unnoticed — the test patches terminal_service.status_monitor wholesale, so status_monitor.seed_from_snapshot.assert_called_once_with(...) only asserts that a MagicMock was called. seed_from_snapshot is referenced nowhere else in test/, so the real implementation is executed by zero tests. What the suite currently pins is the call wiring, not the recovery.

At minimum, add a test that drives the genuine StatusMonitor with a real provider:

def test_seed_primes_screen_so_a_mid_turn_pane_still_reaches_completed():
    sm, prov = StatusMonitor(), ClaudeCodeProvider("t1", "s", "w")
    busy = "\n".join(["● Working", "✻ Cultivating… (12s)", "─" * 60, "❯ ", "─" * 60])
    with patch("...status_monitor.provider_manager") as pm, \
         patch("...status_monitor.bus", MagicMock()):
        pm.get_provider.return_value = prov
        assert sm.seed_from_snapshot("t1", busy) == TerminalStatus.PROCESSING
        sm._process_chunk("t1", "\x1b[2;1H\x1b[2K✻ Crunched for 12s")  # in-place repaint
        sm._apply_detection("t1", sm._detect_screen("t1", prov))
    assert sm._last_status["t1"] == TerminalStatus.COMPLETED  # fails on d9597de

The failure paths in recover_persisted_terminal_output_streams are also untested — get_history raising (stale DB row), create_reader raising, and the list_sessions guard — and those except blocks are the load-bearing part of the "must never prevent server startup" contract in the docstring.

{"id": "abc12345", "tmux_window": "tech_lead-a1b2"}
]
status_monitor.seed_from_snapshot.return_value = TerminalStatus.COMPLETED

assert recover_persisted_terminal_output_streams() == 1

fifo_manager.create_reader.assert_called_once()
backend.stop_pipe_pane.assert_called_once_with("cao-live", "tech_lead-a1b2")
backend.pipe_pane.assert_called_once()
status_monitor.seed_from_snapshot.assert_called_once_with("abc12345", "❯ ready")


@patch("cli_agent_orchestrator.services.terminal_service.get_backend")
def test_recover_persisted_terminal_streams_skips_event_inbox_backend(backend_getter):
from cli_agent_orchestrator.services.terminal_service import (
recover_persisted_terminal_output_streams,
)

backend = MagicMock()
backend.supports_event_inbox.return_value = True
backend_getter.return_value = backend

assert recover_persisted_terminal_output_streams() == 0
backend.list_sessions.assert_not_called()
Loading