Closes #412. The supervise MCP server blocked the agent's tool call polling for the operator's decision, and on timeout returned `status: pending` with no proposal id and no way to poll a specific proposal — so the only way to learn a late decision was to re-propose (a duplicate). - `handle_tools_call` pending timeout now returns the `proposal_id` and points the agent at `check-proposal`. - New `check-proposal` MCP tool: non-blocking status lookup by proposal id (pending | approved | modified | rejected | unknown). Reuses the queue's FileNotFoundError semantics; archives a decided proposal exactly like the synchronous path, so a pending proposal stays visible to the operator until it's both decided and polled. - `TOOL_CHECK_PROPOSAL` constant, re-exported from supervise; kept out of TOOLS since it never becomes a Proposal.tool. Enforcement is unchanged — the tools only propose policy; the egress proxy and git-gate still enforce — so returning early opens no hole. Follow-ups (git-gate reject-requeue, backpressure, notifications, web console) are in the PRD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
5.4 KiB
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:
- Include the
proposal_idin thependingresponse. - Add a
check-proposalMCP tool: a non-blocking status lookup by proposal id. - 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
pendingMCP response carries theproposal_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/callstill 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-receivepath (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:
read_response(slug, id)→- found: archive the proposal (same terminal step the synchronous
path takes) and return the decision via
format_response_text;isErroriff rejected.
- found: archive the proposal (same terminal step the synchronous
path takes) and return the decision via
- 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.
- found: still queued → return
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
- (this PR)
TOOL_CHECK_PROPOSALconstant;check-proposaltool definition +handle_check_proposal; dispatch wiring;proposal_idin 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. - (follow-up) git-gate
pre-receivereject-fast + re-push. - (follow-up) per-bottle in-flight-proposal backpressure cap.
- (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
pendingfrom the original call enough? (Deferred to the notifications chunk.)