Files
odysseus/docs/runtime-decomposition/wave-5a-browser-lifecycle.md
T
Alexandre Teixeira 255bff1f76 fix(browser): derive batch navigation outcome from command rows
A batch whose open succeeded but whose later command failed was recorded
as a failed navigation, so a following observation was wrongly labelled
stale. Use the per-command rows; when the outcome cannot be determined,
treat the page as unknown instead of claiming either result.
2026-10-01 21:03:00 +01:00

121 lines
7.8 KiB
Markdown

# 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-<hash(namespace, session_id)>`.
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
`<session>.{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`. A batch's navigation outcome comes from its per-command rows; when it cannot be determined the page is treated as unknown |
| 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/<uid>`; 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`, `navigation_unknown`, `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.
- Forced cleanup waits synchronously, at most one second, for killed processes
to exit, so it can run from cancellation without awaiting.
- The recovery deadline covers the action and its retry. Post-action
observations (page errors, settled snapshot, screenshot) keep their own
20 second bounds outside it.