Files
bot-bottle/docs/prds/0072-nonblocking-supervise.md
T
didericis-codex 9c06702b32
prd-number / assign-numbers (push) Failing after 25s
lint / lint (push) Successful in 4m7s
Update Quality Badges / update-badges (push) Failing after 13m37s
test / coverage (push) Has been skipped
test / integration-docker (push) Failing after 13m51s
test / integration-firecracker (push) Failing after 13m48s
test / integration-macos (push) Failing after 13m54s
test / unit (push) Failing after 13m53s
test / publish-infra (push) Has been skipped
docs(prd): number merged placeholder documents
2026-07-26 06:06:04 +00:00

5.3 KiB

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 foundread_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.)