fix: mutation budget counts server-side changes only (Closes #617)
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]>
This commit is contained in:
@@ -728,6 +728,65 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
)
|
||||
|
||||
|
||||
_MUTATION_ACCOUNTING_PATTERNS = {
|
||||
"local_failed_attempts": re.compile(
|
||||
r"local\s+failed\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"blocked_api_attempts": re.compile(
|
||||
r"blocked\s+api\s+attempts\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
"successful_server_mutations": re.compile(
|
||||
r"successful\s+server(?:[-\s]side)?\s+mutations\s*:\s*(\d+)", re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
_READBACK_VERIFIED_PATTERN = re.compile(
|
||||
r"read[-\s]?after[-\s]?write\s+verified\s*:\s*(yes|true)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_mutation_budget_accounting(
|
||||
report_text: str,
|
||||
*,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
"""#617: mutation budget counts server-side changes only.
|
||||
|
||||
No-op unless the session supplies an attempt ledger. When it does, the
|
||||
report's three attempt categories must match the ledger exactly, so a
|
||||
pre-API validator rejection can never be reported as a Gitea mutation and
|
||||
a real mutation can never be hidden.
|
||||
"""
|
||||
if mutation_attempt_ledger is None:
|
||||
return []
|
||||
|
||||
from mutation_budget_classifier import assess_final_report_mutation_accounting
|
||||
|
||||
text = report_text or ""
|
||||
claimed: dict[str, Any] = {}
|
||||
for field, pattern in _MUTATION_ACCOUNTING_PATTERNS.items():
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
claimed[field] = int(match.group(1))
|
||||
if _READBACK_VERIFIED_PATTERN.search(text):
|
||||
claimed["readback_verified"] = True
|
||||
|
||||
result = assess_final_report_mutation_accounting(claimed, mutation_attempt_ledger)
|
||||
if result.get("valid"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.mutation_budget_accounting",
|
||||
result.get("reasons") or [],
|
||||
field="Mutation accounting",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"report 'Local failed attempts:', 'Blocked API attempts:', and "
|
||||
"'Successful server-side mutations:' with counts matching the "
|
||||
"attempt ledger; pre-API rejections are not Gitea mutations"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from conflict_fix_classification import (
|
||||
assess_conflict_fix_classification_final_report,
|
||||
@@ -1584,6 +1643,10 @@ _SHARED_CANONICAL_COMMENT_RULES = (
|
||||
_rule_shared_canonical_comment_post_claim,
|
||||
)
|
||||
|
||||
_SHARED_MUTATION_BUDGET_RULES = (
|
||||
_rule_shared_mutation_budget_accounting,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
@@ -1591,6 +1654,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1636,6 +1700,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
*_SHARED_CLEANUP_PROOF_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
@@ -1655,6 +1720,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
@@ -1664,6 +1730,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_shared_issue_acceptance_gate,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
@@ -1677,6 +1744,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
@@ -1685,6 +1753,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
@@ -1694,6 +1763,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_TWO_COMMENT_RULES,
|
||||
*_SHARED_CANONICAL_COMMENT_RULES,
|
||||
*_SHARED_MUTATION_BUDGET_RULES,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
# Controller issue closure (#529): a closure report must not bury an
|
||||
@@ -1766,6 +1836,7 @@ def assess_final_report_validator(
|
||||
session_pr_opened: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
reconciler_close_lock: dict | None = None,
|
||||
mutation_attempt_ledger: list[dict] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate final-report text against task-specific proof rules (#327).
|
||||
|
||||
@@ -1829,6 +1900,7 @@ def assess_final_report_validator(
|
||||
"session_pr_opened": session_pr_opened,
|
||||
"validation_session": validation_session,
|
||||
"reconciler_close_lock": reconciler_close_lock,
|
||||
"mutation_attempt_ledger": mutation_attempt_ledger,
|
||||
}
|
||||
|
||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||
|
||||
Reference in New Issue
Block a user