034f774529
test / integration (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / coverage (pull_request) Successful in 39s
test / unit (pull_request) Successful in 1m30s
prd-number / assign-numbers (push) Failing after 10s
test / integration (push) Successful in 7s
test / unit (push) Successful in 30s
lint / lint (push) Successful in 42s
test / coverage (push) Successful in 35s
Update Quality Badges / update-badges (push) Successful in 34s
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
126 lines
5.4 KiB
Markdown
126 lines
5.4 KiB
Markdown
# 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.)
|