# PRD 0072: Non-blocking supervise (async approval + proposal polling)

- **Status:** Active
- **Author:** didericis
- **Created:** 2026-07-18
- **Issue:** #412

## Summary

The per-bottle supervise MCP server (`bot_bottle/supervise_server.py`)
answers `tools/call` **synchronously**: it queues the agent's proposal and
blocks the tool call polling for the operator's decision. On timeout it
returns `status: pending` and leaves the proposal queued — but it hands the
agent **no proposal id** and offers **no way to poll a specific pending
proposal**, so the only way to learn the outcome is to re-propose (a
duplicate).

This PRD makes the MCP flow non-blocking and pollable, so an approval can
happen out-of-band (a human taking minutes-to-hours in a review console)
without holding an HTTP request open or wedging the agent:

1. Include the `proposal_id` in the `pending` response.
2. Add a `check-proposal` MCP tool: a non-blocking status lookup by
   proposal id.
3. Keep the short synchronous grace window for the common "operator is
   right there" fast path.

## Problem

`handle_tools_call` → `_sv.wait_for_response(...)` blocks up to
`SUPERVISE_RESPONSE_TIMEOUT_SECONDS` (default 30s). Two problems follow:

- **Human latency ≠ tool-call latency.** A real review — rendered diff,
  RBAC routing to an approver, someone tapping approve on their phone — is
  minutes-to-hours. Holding the MCP request open that long is fragile
  (proxy/keepalive timeouts, the mitmproxy egress hop, and the agent
  harness's own tool-call timeout, which a long block can trip and stall
  the whole turn).
- **No resume path.** The pending fallback already exists, but without a
  proposal id and a poll tool the agent can't reconnect to that specific
  decision — it re-proposes, duplicating the queue entry.

This is also the precondition for the planned web-console human-review
flow (RBAC, audit retention, mobile) — see issue #412.

**Safety note:** the MCP tools only *propose* policy changes; enforcement
stays at the egress proxy and the git-gate. Returning early on `pending`
therefore opens no hole — the agent still cannot egress or push anything
unapproved.

## Goals / success criteria

- A `pending` MCP response carries the `proposal_id`.
- An agent can call `check-proposal(proposal_id)` and get the current
  state (`pending` | `approved` | `modified` | `rejected`) **without
  blocking** and **without creating a new proposal**.
- The synchronous fast path (operator approves within the grace window) is
  unchanged: the first `tools/call` still returns the decision directly.
- No change to enforcement, attribution (source-IP → bottle), or the
  operator-side queue/response schema.

## Non-goals

- The git-gate `pre-receive` path (it is synchronous by nature and cannot
  poll — its async variant is reject-fast + re-push; tracked as a
  follow-up).
- Backpressure / in-flight-proposal caps.
- MCP server→client notifications (event-driven resume).
- Any web-console UI (this PRD is the protocol groundwork it needs).

## Design

### `pending` response carries the id

`handle_tools_call`'s timeout branch formats the pending text with the
`proposal.id` and a pointer to `check-proposal`, so the agent knows what to
poll.

### `check-proposal` tool

A new read-only MCP tool (`TOOL_CHECK_PROPOSAL = "check-proposal"`),
attributed to the calling bottle by source IP exactly like the proposal
tools. Input: `{ "proposal_id": string }`. Behavior:

1. `read_response(slug, id)` →
   - **found**: archive the proposal (same terminal step the synchronous
     path takes) and return the decision via `format_response_text`;
     `isError` iff rejected.
2. **not found** → `read_proposal(slug, id)` →
   - **found**: still queued → return `status: pending`.
   - **not found**: unknown id, or already resolved-and-archived (e.g. a
     second poll) → return `status: unknown`, `isError: true`.

Both lookups already raise `FileNotFoundError` when absent
(`queue_store.py`), so the handler needs no new store methods. `check-`
`proposal` is the only path (besides the synchronous response) that
archives, so a proposal that times out to `pending` stays visible to the
operator until it is decided and then polled.

### Grace window

Left at the existing 30s default (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`),
which doubles as the instant-approve fast path. Tuning it down is an
operator setting, not a code change; noted for the console rollout.

## Implementation chunks

1. **(this PR)** `TOOL_CHECK_PROPOSAL` constant; `check-proposal` tool
   definition + `handle_check_proposal`; dispatch wiring; `proposal_id` in
   the pending text; unit tests. Files: `bot_bottle/supervise_types.py`,
   `bot_bottle/supervise.py` (re-export), `bot_bottle/supervise_server.py`,
   `tests/unit/test_supervise_server.py`.
2. **(follow-up)** git-gate `pre-receive` reject-fast + re-push.
3. **(follow-up)** per-bottle in-flight-proposal backpressure cap.
4. **(follow-up)** MCP notifications for event-driven resume; web-console
   review flow (RBAC, audit retention) on top.

## Open questions

- Should a resolved-but-unpolled proposal auto-archive after some TTL, or
  only on poll? (Leaning: only on poll, so a decision is never lost to a
  reaper before the agent sees it.)
- Does the agent harness need an explicit "you have a pending proposal"
  nudge, or is returning `pending` from the original call enough? (Deferred
  to the notifications chunk.)
