# browser_handoff — Cloud Browser human-handoff park/resume (WS-6)

The executable form of contracts **S5** (handoff ↔ runtime park/resume) and
**S6** (tool surface / typed outcomes) of the Persistent Cloud Browser program
(`common-docs/projects/persistent-cloud-browser/`). This is the matrx-ai +
runtime-coordination half; the Browser Manager (worker/gateway) lives in
`matrx-scraper`, the frontend panel in matrx-frontend.

## The one load-bearing decision (S5 §0)

A Cloud Browser handoff is **not a new suspension mechanism**. It is a
client-delegated tool call whose "client" is a human at a Cloud Browser panel.
This module adds ONLY the human-episode lifecycle on top of the platform's
existing delegated path + the `matrx-runtime` spine. It never builds a waiter,
a lease table, a resume endpoint family, a new abandonment constant, a
per-handoff timer, or a second CAS over the pending-call ledger.

## Files

- `models.py` — the wire shapes crossing every seam + the `HandoffSource`
  protocol the Browser Manager (or the test `FakeHandoffSource`) implements.
- `seam.py` — `set_handoff_ledger` / `get_handoff_ledger`: the injected durable
  pending-call operations (`delegate` = P3, `resolve` = R4). **No fallback** —
  an unwired ledger raises `HandoffLedgerUnavailable` (mirrors the mandate seam).
- `coordinator.py` — `BrowserHandoffCoordinator` (`park` P1–P5, `resume` R1–R5)
  and `Reconciler` (restart-safe re-drive from durable rows).
- `outcomes.py` — the additive S6 tool payloads a producer builds
  (`ok_identity`, `human_required_output`, `reopened_for_handoff_output`,
  `page_inventory_payload`, `new_error`).
- `handle.py` — the D-3 stable agent-facing handle (== opaque `profile_id`;
  `run_id` is additive per-run detail; survives a reopen).

## The three exactly-once CAS gates (S5 §4)

1. **Human episode** — CAS on `browser.handoff.state` (Browser Manager).
2. **Controller** — CAS on `browser.run.controller_revision` (the fencing token).
3. **Pending tool** — the platform's existing CAS on `chat.tool_call` via the
   ledger seam. **This module writes no second CAS over that table.**
   Plus: the resume DRIVER is elected by `engine.start()`'s `WAITING_INPUT →
   RUNNING` CAS. Every loser of every gate returns a success-shaped idempotent
   result — pressing Return twice never surfaces an error.

`resolution_key = f"{handoff_id}:{pending_call_id}"` — stamped at park time,
derivable from durable state alone, never a per-attempt id.

## Invariants (S5 §9), each mechanically checkable

- No `asyncio.sleep`/`Future`/`Event`/in-process dict tracks a pending handoff.
- No new abandonment constant — timing rides `DELEGATED_CALL_ABANDON_AFTER_SECONDS`.
- Every park writes a checkpoint whose kind is `browser_handoff` (`CHECKPOINT_KIND`).
- The parked state is `waiting_input`, never `paused`.
- The model's tool output carries no ticket/cookie/secret/URL-to-artifact and a
  literal `human_outcome_is_unverified=True`.

## `profile_id` (S6 §3)

Every one of the nine `browser_*` tools gains one optional `profile_id: str = ""`
(opaque; `""` ⇒ resolve the acting user's personal default, server-side).
`user_id`/`organization_id`/etc. are **structurally forbidden** as arguments —
`extra="forbid"` on every arg model (layer 1) + `test_browser_args_forbid.py`
(layer 2, survives someone relaxing `extra="forbid"`). The acting user travels
as a signed claim on the transport (D-4), never in a model-authored argument.

## Tests (WS-6 Definition of Done)

`packages/matrx-ai/tests/`:
- `test_browser_handoff_park_resume.py` — the whole protocol against the REAL
  `matrx-runtime` `ExecutionEngine` + `InMemoryExecutionStore` and the REAL
  ledger seam, browser side faked (`browser_handoff_fakes.py`). Proves park +
  resume EXACTLY ONCE across a **kill-the-process** restart and duplicate
  return/resume; reconciler re-drive at each step boundary; never-returned
  supersession; park refusal; orphan alarm; stale fencing token; expiry.
- `test_browser_args_forbid.py`, `test_browser_s6_outcomes.py`,
  `test_browser_s6_fixtures.py` (+ the 19-file `fixtures/browser_s6/` corpus
  shared byte-identically with WS-8).

Run: `uv run pytest packages/matrx-ai/tests -k "browser_handoff or browser_s6 or browser_args"`.

## Host wiring (aidream — DONE, P2-A)

`matrx_ai.configure(handoff_ledger=…)` installs the durable ledger. The real
impl is `aidream/services/browser_handoff/ledger.py::AidreamHandoffLedger`,
installed once at boot by `install_handoff_ledger()` from
`package_integration.py`:

- **`delegate` (P3)** reuses the canonical `ToolExecutionLogger.log_delegated`
  write inside a `standalone_coordinator` scope (which finalizes on exit — the
  same pre-suspend-commit guarantee the executor's delegated path makes), and
  pins the parked run's `runtime_execution_id` from `execution_id` via an
  ambient `system_app_context`. No second ledger, no second CAS.
- **`resolve` (R4)** routes the human's outcome through the ONE canonical
  `resolve_client_tool_results` resolver (its own idempotent per-`call_id` CAS),
  under a `system_app_context` for the returning user, and maps its response to
  the `HandoffResolutionReceipt`. A non-conversation (workflow) flavor has no
  chat ledger row and returns a benign `already_resolved` no-op (no auto-resume
  in v1, S5 §0.2 / §7).

`suspended_awaiting_human_browser` is now in
`CompletedRequest.RESUMABLE_SUSPEND_STATUSES` (kept in lockstep with
`SUSPEND_REASON`), so a run whose settle status is the browser suspend is parked
at WAITING_INPUT (never a terminal, never `paused`).

The browser tool's `human_required` / `reopened_for_handoff` outcomes are routed
in `matrx_ai/tools/implementations/browser.py::_to_handoff_result` as
NON-RETRYABLE SUCCESSES through the S6 outcome builders — a failure result would
make the executor retry the exact thing that needs a human (S6 §5.3).

**Remaining go-live seam (Browser Manager owns the trigger side):** the
`coordinator.park(...)` CALL that drives the durable delegate needs the
`WorkerQuiesced` + `HandoffSource` from the live scraper-side Browser Manager
(matrx-scraper), which is not available offline. The tool surfaces the
non-retryable success and the ledger is wired; the scraper's handoff endpoints
supplying `WorkerQuiesced`/`HandoffSource` are the last connection. Registering
the nine server-side `browser_*` tools onto a live agent surface
(`tool.definition` + `tool.binding(aidream)` + `tool.surface_defaults` + the
matching `_reg` entries) must land as ONE code+DB change (the boot drift gate,
`aidream/startup/tools_check.py`, blows up on either half alone).

## Known limits

- The `reason` enum uses S6 §5.2 spellings; S5 §5.1 spells three literals
  differently — `OPEN(reason-enum ownership sync)`, S1 owns the final set.
- The legacy fixture anchors assert the documented byte-shape; live-equality to
  the shipped scraper output needs the scraper running (not available offline).
- Non-conversation (workflow) flavor parks but does not auto-resume in v1
  (S5 §0.2 / §7, `OPEN(workflow-flavor handoff resume)`).
