Auto-mode classifier now distinguishes local validator rejection, capability-gate rejection, transport failure before API, and successful server-side mutation. Pre-API validator failures no longer consume server-side mutation budget; the final report separately accounts for local failed attempts, blocked API attempts, and successful server-side mutations. Recovered from preserved unpublished commit b46f0f9 via native MCP unpublished-claim recovery (#772) and author-worktree lock binding (#618), reconciled onto current master. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
287 lines
9.7 KiB
Python
287 lines
9.7 KiB
Python
"""Mutation-budget classification for auto-mode attempts (#617).
|
|
|
|
Mutation budget must count only *server-side* Gitea state changes. A tool call
|
|
that fails closed before the Gitea API is reached changed nothing on the
|
|
server, so it must not consume the budget that protects against repeated real
|
|
mutations.
|
|
|
|
The classifier separates four outcome classes plus an explicit ambiguous class:
|
|
|
|
``local_validator_rejection``
|
|
A canonical-content validator (for example the ``[THREAD STATE LEDGER]`` or
|
|
``## Canonical Issue State`` blocks) rejected the payload before any API
|
|
call. No server-side state exists.
|
|
|
|
``capability_gate_rejection``
|
|
A profile/permission gate refused the operation before any API call.
|
|
|
|
``transport_failure_before_api``
|
|
The request never reached the Gitea API (transport/EOF/connection error).
|
|
|
|
``server_side_mutation``
|
|
The API succeeded and returned proof of durable state (comment id, review
|
|
id, merge commit, label result, or an issue/PR state change).
|
|
|
|
``ambiguous_requires_readback``
|
|
The API *was* reached but the result carries no usable proof either way.
|
|
This fails closed: the attempt is treated as budget-consuming until a
|
|
read-after-write check proves otherwise, so #617 never weakens the guard
|
|
that prevents repeated real mutations.
|
|
|
|
Only ``server_side_mutation`` consumes budget outright. Every attempt — failed
|
|
or not — is still recorded in the local attempt ledger so a final report can
|
|
show local failed attempts, blocked API attempts, and successful server-side
|
|
mutations separately.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
LOCAL_VALIDATOR_REJECTION = "local_validator_rejection"
|
|
CAPABILITY_GATE_REJECTION = "capability_gate_rejection"
|
|
TRANSPORT_FAILURE_BEFORE_API = "transport_failure_before_api"
|
|
SERVER_SIDE_MUTATION = "server_side_mutation"
|
|
AMBIGUOUS_REQUIRES_READBACK = "ambiguous_requires_readback"
|
|
|
|
CLASSIFICATIONS = (
|
|
LOCAL_VALIDATOR_REJECTION,
|
|
CAPABILITY_GATE_REJECTION,
|
|
TRANSPORT_FAILURE_BEFORE_API,
|
|
SERVER_SIDE_MUTATION,
|
|
AMBIGUOUS_REQUIRES_READBACK,
|
|
)
|
|
|
|
#: Result fields that prove durable server-side state was created.
|
|
MUTATION_PROOF_FIELDS = (
|
|
"comment_id",
|
|
"review_id",
|
|
"merge_commit_sha",
|
|
"label_result",
|
|
"state_change",
|
|
"created_pr_number",
|
|
)
|
|
|
|
#: Classes that never consume server-side mutation budget.
|
|
PRE_API_CLASSIFICATIONS = (
|
|
LOCAL_VALIDATOR_REJECTION,
|
|
CAPABILITY_GATE_REJECTION,
|
|
TRANSPORT_FAILURE_BEFORE_API,
|
|
)
|
|
|
|
FINAL_REPORT_REQUIRED_FIELDS = (
|
|
"local_failed_attempts",
|
|
"blocked_api_attempts",
|
|
"successful_server_mutations",
|
|
)
|
|
|
|
|
|
def _clean(value: Any) -> str:
|
|
return (value or "").strip() if isinstance(value, str) else str(value or "").strip()
|
|
|
|
|
|
def _proof_fields_present(result: dict) -> list[str]:
|
|
"""Return the mutation-proof fields carrying a usable value."""
|
|
present: list[str] = []
|
|
for field in MUTATION_PROOF_FIELDS:
|
|
value = result.get(field)
|
|
if value is None or value is False:
|
|
continue
|
|
if isinstance(value, str) and not value.strip():
|
|
continue
|
|
present.append(field)
|
|
return present
|
|
|
|
|
|
def _decision(
|
|
classification: str,
|
|
*,
|
|
budget_consumed: bool,
|
|
requires_readback: bool,
|
|
reasons: list[str],
|
|
proof_fields: list[str],
|
|
api_called: bool | None,
|
|
) -> dict:
|
|
return {
|
|
"classification": classification,
|
|
"budget_consumed": budget_consumed,
|
|
"requires_readback": requires_readback,
|
|
"pre_api": classification in PRE_API_CLASSIFICATIONS,
|
|
"api_called": api_called,
|
|
"proof_fields": proof_fields,
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
def classify_mutation_attempt(result: dict | None) -> dict:
|
|
"""Classify one mutation attempt and decide whether it consumes budget.
|
|
|
|
``result`` is the raw dict a Gitea MCP tool returned. The caller does not
|
|
pre-interpret it: classification is driven by the explicit ``api_called``
|
|
signal plus the proof fields the tool reports.
|
|
"""
|
|
data = dict(result or {})
|
|
success = bool(data.get("success"))
|
|
proof_fields = _proof_fields_present(data)
|
|
api_called = data.get("api_called")
|
|
|
|
# An unambiguous success carrying durable proof is a real mutation however
|
|
# the attempt was labelled upstream.
|
|
if success and proof_fields:
|
|
return _decision(
|
|
SERVER_SIDE_MUTATION,
|
|
budget_consumed=True,
|
|
requires_readback=False,
|
|
reasons=[
|
|
"API reported success with durable proof field(s): "
|
|
+ ", ".join(proof_fields)
|
|
],
|
|
proof_fields=proof_fields,
|
|
api_called=True,
|
|
)
|
|
|
|
if api_called is False:
|
|
# Nothing reached the server; pick the precise pre-API class.
|
|
if data.get("transport_error") or data.get("transport_failed"):
|
|
return _decision(
|
|
TRANSPORT_FAILURE_BEFORE_API,
|
|
budget_consumed=False,
|
|
requires_readback=False,
|
|
reasons=["transport failed before the Gitea API was reached"],
|
|
proof_fields=[],
|
|
api_called=False,
|
|
)
|
|
if data.get("permission_report") or data.get("capability_blocked"):
|
|
return _decision(
|
|
CAPABILITY_GATE_REJECTION,
|
|
budget_consumed=False,
|
|
requires_readback=False,
|
|
reasons=["capability/permission gate refused before any API call"],
|
|
proof_fields=[],
|
|
api_called=False,
|
|
)
|
|
return _decision(
|
|
LOCAL_VALIDATOR_REJECTION,
|
|
budget_consumed=False,
|
|
requires_readback=False,
|
|
reasons=[
|
|
"local validator rejected the payload before any API call; "
|
|
"no server-side state was created"
|
|
],
|
|
proof_fields=[],
|
|
api_called=False,
|
|
)
|
|
|
|
if api_called is True:
|
|
if success:
|
|
reason = (
|
|
"API reported success but returned no durable proof field; "
|
|
"read-after-write verification required before counting budget"
|
|
)
|
|
else:
|
|
reason = (
|
|
"API was reached and the outcome carries no durable proof; "
|
|
"read-after-write verification required before counting budget"
|
|
)
|
|
return _decision(
|
|
AMBIGUOUS_REQUIRES_READBACK,
|
|
budget_consumed=True,
|
|
requires_readback=True,
|
|
reasons=[reason],
|
|
proof_fields=proof_fields,
|
|
api_called=True,
|
|
)
|
|
|
|
# ``api_called`` was not reported at all. Fail closed rather than assuming
|
|
# nothing happened.
|
|
return _decision(
|
|
AMBIGUOUS_REQUIRES_READBACK,
|
|
budget_consumed=True,
|
|
requires_readback=True,
|
|
reasons=[
|
|
"attempt did not report 'api_called'; cannot prove the request "
|
|
"stopped before the Gitea API, so the attempt fails closed"
|
|
],
|
|
proof_fields=proof_fields,
|
|
api_called=None,
|
|
)
|
|
|
|
|
|
def record_attempt(
|
|
ledger: list[dict] | None,
|
|
result: dict | None,
|
|
*,
|
|
operation: str = "",
|
|
timestamp: str | None = None,
|
|
) -> dict:
|
|
"""Append one classified attempt to the local ledger and return the entry.
|
|
|
|
Every attempt is recorded, including the ones that consume no budget: the
|
|
point of #617 is that failed local attempts stay visible without being
|
|
miscounted as Gitea mutations.
|
|
"""
|
|
entries = ledger if isinstance(ledger, list) else []
|
|
entry = {
|
|
"operation": _clean(operation),
|
|
"timestamp": _clean(timestamp) or datetime.now(timezone.utc).isoformat(),
|
|
**classify_mutation_attempt(result),
|
|
}
|
|
entries.append(entry)
|
|
return entry
|
|
|
|
|
|
def summarize_attempt_ledger(ledger: list[dict] | None) -> dict:
|
|
"""Summarize a ledger into the categories a final report must show."""
|
|
entries = [e for e in (ledger or []) if isinstance(e, dict)]
|
|
|
|
def _count(*classifications: str) -> int:
|
|
return sum(1 for e in entries if e.get("classification") in classifications)
|
|
|
|
return {
|
|
"total_attempts": len(entries),
|
|
"local_failed_attempts": _count(LOCAL_VALIDATOR_REJECTION),
|
|
"blocked_api_attempts": _count(
|
|
CAPABILITY_GATE_REJECTION, TRANSPORT_FAILURE_BEFORE_API
|
|
),
|
|
"successful_server_mutations": _count(SERVER_SIDE_MUTATION),
|
|
"ambiguous_attempts": _count(AMBIGUOUS_REQUIRES_READBACK),
|
|
"budget_consumed": sum(1 for e in entries if e.get("budget_consumed")),
|
|
"requires_readback": any(e.get("requires_readback") for e in entries),
|
|
"entries": entries,
|
|
}
|
|
|
|
|
|
def assess_final_report_mutation_accounting(
|
|
report: dict | None,
|
|
ledger: list[dict] | None,
|
|
) -> dict:
|
|
"""Fail closed when a report's mutation accounting contradicts the ledger."""
|
|
data = dict(report or {})
|
|
summary = summarize_attempt_ledger(ledger)
|
|
reasons: list[str] = []
|
|
|
|
for field in FINAL_REPORT_REQUIRED_FIELDS:
|
|
if field not in data:
|
|
reasons.append(f"final report omits required field '{field}'")
|
|
continue
|
|
claimed = data.get(field)
|
|
actual = summary[field]
|
|
if claimed != actual:
|
|
reasons.append(
|
|
f"final report claims {field}={claimed} but the attempt ledger "
|
|
f"shows {actual}"
|
|
)
|
|
|
|
if summary["requires_readback"] and not data.get("readback_verified"):
|
|
reasons.append(
|
|
"ledger contains an ambiguous attempt; final report must record "
|
|
"'readback_verified' proof before claiming mutation accounting"
|
|
)
|
|
|
|
return {
|
|
"valid": not reasons,
|
|
"reasons": reasons,
|
|
"ledger_summary": {k: v for k, v in summary.items() if k != "entries"},
|
|
}
|