fix(supervise): make the response poll idempotent and slow it to 250ms
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / integration-docker (pull_request) Successful in 42s
test / unit (pull_request) Successful in 46s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m22s
test / coverage (pull_request) Successful in 23s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Successful in 15s
test / integration-docker (pull_request) Successful in 42s
test / unit (pull_request) Successful in 46s
lint / lint (push) Successful in 57s
test / integration-firecracker (pull_request) Successful in 3m22s
test / coverage (pull_request) Successful in 23s
test / publish-infra (pull_request) Has been skipped
The control-plane supervise poll archived the proposal on the same call that returned the decision, so a dropped connection lost the operator's decision (the retry saw `unknown`). Poll no longer archives — a re-poll returns the same decision. Decided proposals still drop off the operator's pending list (a response row exists); their rows are reaped when the bottle is torn down or reconciled. Also raise the grace-window poll interval from 50ms to 250ms now that each poll is an HTTP round trip rather than a local file read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -35,10 +35,12 @@ vsock / unix-socket portability caveats):
|
||||
"proposal_id"}
|
||||
|
||||
The `/supervise/propose` + `/supervise/poll` pair is the **agent** half of the
|
||||
supervise flow: the data plane (supervise / egress / git-gate) queues a
|
||||
proposal and polls for its response over RPC instead of opening `bot-bottle.db`
|
||||
directly. Both attribute the caller by `(source_ip, identity_token)` exactly
|
||||
like `/resolve`, so a bottle can only ever queue or read its own proposals.
|
||||
supervise flow: the data plane (supervise / egress / git-gate) queues a proposal
|
||||
and polls for its response over RPC instead of opening `bot-bottle.db` directly.
|
||||
`poll` is idempotent — it never archives, so a dropped connection can't lose an
|
||||
operator decision (the row is reaped when the bottle is torn down / reconciled).
|
||||
Both attribute the caller by `(source_ip, identity_token)` exactly like
|
||||
`/resolve`, so a bottle can only ever queue or read its own proposals.
|
||||
|
||||
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
||||
tear down) the bottle in the registry AND broker the backend-native launch
|
||||
|
||||
@@ -40,7 +40,7 @@ from ..supervise import (
|
||||
STATUS_REJECTED,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
archive_proposal,
|
||||
archive_all_proposals,
|
||||
list_all_pending_proposals,
|
||||
read_proposal,
|
||||
read_response,
|
||||
@@ -136,6 +136,8 @@ class Orchestrator:
|
||||
self._broker.submit(sign_request(req, self._secret))
|
||||
self.registry.deregister(bottle_id)
|
||||
self._tokens.pop(bottle_id, None)
|
||||
# Reap any supervise proposals the gone bottle never acknowledged.
|
||||
archive_all_proposals(bottle_id)
|
||||
return True
|
||||
|
||||
def reconcile(
|
||||
@@ -160,6 +162,8 @@ class Orchestrator:
|
||||
live_source_ips, grace_seconds=grace_seconds)
|
||||
for rec in reaped:
|
||||
self._tokens.pop(rec.bottle_id, None)
|
||||
# Reap any supervise proposals the gone bottle never acknowledged.
|
||||
archive_all_proposals(rec.bottle_id)
|
||||
return [rec.bottle_id for rec in reaped]
|
||||
|
||||
def tokens_for(self, bottle_id: str) -> dict[str, str]:
|
||||
@@ -223,15 +227,19 @@ class Orchestrator:
|
||||
return proposal.id
|
||||
|
||||
def supervise_poll_response(self, bottle_id: str, proposal_id: str) -> dict[str, object]:
|
||||
"""Non-blocking poll of one of `bottle_id`'s queued proposals.
|
||||
"""Non-blocking, **idempotent** poll of one of `bottle_id`'s proposals.
|
||||
|
||||
Returns `{"status": ...}`: a terminal decision (`approved`/`modified`/
|
||||
`rejected`, with `notes` + `final_file`) once the operator has responded,
|
||||
`pending` while it's still queued undecided, or `unknown` when no such
|
||||
queued proposal exists for this bottle (wrong id, or already resolved and
|
||||
read). A decided proposal is archived here — the same terminal step the
|
||||
data plane used to run after reading the response — so `pending`
|
||||
proposals stay visible to the operator until decided *and* polled.
|
||||
queued proposal exists for this bottle (wrong id, or already archived).
|
||||
|
||||
Poll does **not** archive — a decided proposal keeps returning the same
|
||||
decision so a client whose connection drops after the decision but before
|
||||
it consumes the response still gets it on retry (issue #469 review). A
|
||||
decided proposal is already excluded from the operator's pending list (a
|
||||
response row exists), so it doesn't linger there; the row itself is reaped
|
||||
when the bottle is torn down / reconciled.
|
||||
|
||||
Reads are scoped to `bottle_id` (the queue key), so a caller can never
|
||||
read another bottle's proposal even with a guessed id."""
|
||||
@@ -243,7 +251,6 @@ class Orchestrator:
|
||||
except FileNotFoundError:
|
||||
return {"status": POLL_STATUS_UNKNOWN}
|
||||
return {"status": POLL_STATUS_PENDING}
|
||||
archive_proposal(bottle_id, proposal_id)
|
||||
return {
|
||||
"status": response.status,
|
||||
"notes": response.notes,
|
||||
|
||||
@@ -192,6 +192,23 @@ class QueueStore(DbStore):
|
||||
(self.queue_key, proposal_id),
|
||||
)
|
||||
|
||||
def archive_all(self) -> None:
|
||||
"""Archive every proposal + response for this queue key. Used to reap a
|
||||
bottle's supervise records when it is torn down or reconciled away —
|
||||
the retention path for a client that received a decision but died before
|
||||
acknowledging it. Idempotent."""
|
||||
if not self.db_path.is_file():
|
||||
return
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE supervise_proposals SET archived = 1 WHERE queue_key = ?",
|
||||
(self.queue_key,),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE supervise_responses SET archived = 1 WHERE queue_key = ?",
|
||||
(self.queue_key,),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _row_to_proposal(row: sqlite3.Row) -> Proposal:
|
||||
return Proposal(
|
||||
|
||||
@@ -171,6 +171,12 @@ def archive_proposal(bottle_slug: str, proposal_id: str) -> None:
|
||||
QueueStore(bottle_slug).archive_proposal(proposal_id)
|
||||
|
||||
|
||||
def archive_all_proposals(bottle_slug: str) -> None:
|
||||
"""Archive every proposal + response for `bottle_slug` (bottle teardown /
|
||||
reconcile cleanup). Idempotent."""
|
||||
QueueStore(bottle_slug).archive_all()
|
||||
|
||||
|
||||
# --- Audit log -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -275,6 +281,7 @@ __all__ = [
|
||||
"TOOL_EGRESS_TOKEN_ALLOW",
|
||||
"TOOL_LIST_EGRESS_ROUTES",
|
||||
"archive_proposal",
|
||||
"archive_all_proposals",
|
||||
"audit_dir",
|
||||
"audit_log_path",
|
||||
"list_pending_proposals",
|
||||
|
||||
@@ -82,7 +82,11 @@ ERR_INVALID_PARAMS = -32602
|
||||
ERR_INTERNAL = -32603
|
||||
|
||||
DEFAULT_RESPONSE_TIMEOUT_SECONDS = 30.0
|
||||
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.05
|
||||
# Grace-window poll cadence. 250 ms keeps the operator-visible latency low while
|
||||
# staying gentle on the shared control plane — each poll is now an HTTP round
|
||||
# trip, not a local file read, so the old 50 ms would be ~20 req/s per pending
|
||||
# proposal across every waiting agent (issue #469 review).
|
||||
MIN_RESPONSE_POLL_INTERVAL_SECONDS = 0.25
|
||||
|
||||
# The per-host orchestrator control plane the shared supervise server attributes
|
||||
# each proposal to, by source IP. Mandatory — there is no single-tenant
|
||||
|
||||
Reference in New Issue
Block a user