# Wave 5A: deterministic browser lifecycle Base: `a46eb7f47abaf15c799275f946d7dfe27bdee516`, branch `feature/browser-lifecycle`. Scope is browser-specific lifecycle only. Request authority, approvals, TurnContract, generic process containment (Wave 3-S), effects/provenance (Wave 4), generic process lifecycle (Wave 5B) and runtime decomposition (Wave 6) are unchanged. ## Runtimes 1. `private_browser` (`src/agent_tools/web_tools.py`, `PrivateBrowserTool`) is the model-facing browser. It runs the `agent-browser` CLI per action. The CLI is a short-lived client of a detached daemon; the daemon calls `setsid` and launches Chrome. Identity is `--session ody-`. Other entry points: `src/research_navigator.py` (`browser_read`), `scripts/probe_browser_budget.py`, app shutdown in `app.py`. 2. Playwright MCP (`src/builtin_mcp.py`, server `builtin_browser`) is one global `npx @playwright/mcp --headless --isolated --no-sandbox` stdio server owned by `src/mcp_manager.py`. Its tools are hidden from the model unless `private_browser` is disabled or `ODYSSEUS_EXPOSE_RAW_BROWSER_MCP` is set (`src/agent_loop.py`, `_should_hide_raw_browser_mcp`). The two runtimes share no code; only Chromium discovery overlaps. ## Probe evidence (agent-browser 0.27.0, this host) - Runtime files live in `AGENT_BROWSER_SOCKET_DIR`, else `$XDG_RUNTIME_DIR/agent-browser`, else `$HOME/.agent-browser`, as `.{pid,sock,stream,version,engine}`. The socket path must stay under about 103 bytes. - Every Chrome process shares the daemon's POSIX session id (sid == daemon pid). - `close` removes the daemon, Chrome, the runtime files and the `agent-browser-chrome-*` profile. - SIGKILL of the daemon alone (the previous timeout path) left 13 Chrome processes, the profile, a Chromium temp directory and stale pid/socket files. - `close` against a session with no daemon bootstraps one. - A Chrome launch failure ("No usable sandbox", "Chrome exited early") leaves the daemon alive; `close` cannot reach a browser. - There is no `read` command ("Unknown command: read"). - This host blocks the Chromium sandbox for agent-browser. Tests pass `AGENT_BROWSER_ARGS=--no-sandbox` in the test environment only; production launch flags are unchanged. ## Failure modes found and their resolution | # | Failure | Resolution | |---|---------|------------| | F1 | Cancellation not handled; CLI, daemon and Chrome survived until idle timeout | `execute` catches `CancelledError`, kills every CLI client of the call and cleans the session tree, then re-raises | | F2 | Timeout/exception killed only the daemon; Chrome reparented and leaked | `browser_lifecycle.force_cleanup` kills the daemon's whole POSIX session, removes runtime files and the profile, and verifies no survivor | | F3 | Shutdown force-kill used `os.environ` and only the legacy layout | Shutdown uses each session's recorded launch environment, closes only verified live daemons, then force-cleans and verifies | | F4 | Missing `session_id` used agent-browser's shared `default` session | A sessionless call gets an ephemeral session that is closed and verified before the call returns | | F5 | Launch failure left the daemon alive | Launch-failure output triggers forced cleanup and a truthful error | | F6 | Concurrent actions on one session raced one daemon | Per-session `asyncio.Lock` serializes actions | | F7 | Observation after a failed navigation silently showed the old page | Sessions track navigation generation, page URL and failed navigation; such observations are prefixed with an explicit stale notice and flagged `stale_observation` | | F8 | Recovery recursed through `execute` with a model-visible retry flag and no overall deadline | One deadline per call (action timeout + 75s); at most one retry, only for local read-only HTML open; model-supplied `_odysseus_browser_retry` is ignored | | F9 | `research_navigator` passed `timeout`, which the tool ignored | Passes `timeout_ms` | | F10 | No lifecycle evidence | Every result carries `browser_lifecycle` with stages, timings, ownership, state and cleanup receipt | | F11 | Pid lookup assumed `/run/user/`; containers without `XDG_RUNTIME_DIR` were never cleaned | Runtime root follows agent-browser's own resolution from the launch environment | | F12 | Per-call timeout swept every Chrome under the runtime `TMPDIR`, killing other sessions | Per-call cleanup is limited to the session tree; the `TMPDIR` sweep only runs at runtime shutdown | | F13 | `read` used a command agent-browser does not have | `read URL` runs `open` and `get text body` in one batch; success requires both rows; `read` without URL extracts the current page | | F14 | Playwright MCP calls had no time bound | `builtin_browser` calls are bounded by `ODYSSEUS_BROWSER_MCP_CALL_TIMEOUT_S` (default 90) and are not retried | ## Lifecycle model Session states: `idle`, `ready`, `navigation_failed`, `reset`, `timed_out`, `failed`, `launch_failed`, `bootstrap_failed`, `cancelled`, `closed`. Any state reached by forced cleanup discards the page URL so nothing earlier remains observable. Ownership is `retained` for a chat session (bounded by `AGENT_BROWSER_IDLE_TIMEOUT_MS`, default 300000, and cleaned at shutdown) or `ephemeral` for a sessionless call. The `browser_lifecycle` result field: ```json {"session": "ody-...", "ownership": "retained", "state": "ready", "navigation_generation": 2, "page_url": "file:///...", "stages": [{"stage": "open", "ms": 210, "ok": true, "cold_start": true}], "elapsed_ms": 230, "cleanup": {"method": "forced", "verified": true, "...": "..."}, "recovery_attempts": 1, "stale_observation": true, "closed_page_url": "..."} ``` Optional keys appear only when relevant. ## Ownership boundary `src/browser_lifecycle.py` holds the browser-specific process attribution. It claims processes only through the session's own pid file and the daemon's POSIX session; once the daemon is gone it claims only Chrome process groups whose root carries an `agent-browser-chrome-*` profile. Without procfs it kills nothing. `kill_browser_tree` is the single seam to replace with the shared process-lifecycle primitives from Wave 3-S/5B. ## Files - New: `src/browser_lifecycle.py`, `tests/test_browser_lifecycle.py`, this document. - Changed: `src/agent_tools/web_tools.py` (`PrivateBrowserTool` and shutdown), `src/research_navigator.py` (timeout argument), `src/mcp_manager.py` (bounded `builtin_browser` call), `scripts/generate_env_reference.py` and `website/configuration-reference.md` (new variable), `tests/test_private_browser_tool.py` (shutdown and read fakes). - Not touched: `src/agent_loop.py`, `src/tool_execution.py`, `src/agent_runtime/authority.py`, approvals, task and background infrastructure. ## Limitations - A retained session's browser is not closed when its chat session is deleted; it is bounded by the idle timeout and shutdown cleanup. - The in-process session registry keeps one small record per chat session that used the browser until shutdown. - Chromium temp directories outside the profile (`org.chromium.Chromium.*`) are not attributable to one session and are not removed by forced cleanup. - Playwright MCP remains one global browser shared by all sessions. A timed-out call is abandoned but the server is not restarted, because restarting the npx server requires its owner task in `builtin_mcp.py`. - The stale-observation notice marks, but does not block, an observation after a failed navigation.