# PRD prd-new: Non-blocking supervise (async approval + proposal polling) - **Status:** Draft - **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.)