fix(agent): harden apply closure and telegram ownership
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m36s
CD Pipeline / build-and-deploy (push) Successful in 7m2s
CD Pipeline / post-deploy-checks (push) Successful in 1m53s
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m36s
CD Pipeline / build-and-deploy (push) Successful in 7m2s
CD Pipeline / post-deploy-checks (push) Successful in 1m53s
This commit is contained in:
@@ -20,10 +20,11 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from src.core.config import settings
|
||||
from src.db.base import get_db_context
|
||||
from src.db.models import PlaybookRecord
|
||||
from src.services.ai_automation_runtime_contract import (
|
||||
AI_AUTOMATION_STAGE_RECEIPT_SCHEMA_VERSION,
|
||||
)
|
||||
@@ -36,6 +37,7 @@ from src.services.awooop_ansible_learning_writeback import (
|
||||
from src.services.awooop_ansible_post_verifier import (
|
||||
run_ansible_asset_post_verifier,
|
||||
)
|
||||
from src.services.playbook_evolver import TRUST_ARCHIVE_THRESHOLD
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -43,6 +45,9 @@ _SAFE_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
||||
_PLAYBOOK_PREFIX = Path("infra/ansible/playbooks")
|
||||
_STDOUT_LIMIT = 20_000
|
||||
_STDERR_LIMIT = 12_000
|
||||
_CONTROLLED_RETRY_MAX_ATTEMPTS = 1
|
||||
_CONTROLLED_RETRY_ERROR_BACKOFF_MIN_SECONDS = 15
|
||||
_CONTROLLED_RETRY_ERROR_BACKOFF_MAX_SECONDS = 30
|
||||
_EXECUTION_CAPABILITY_MIN_TTL_SECONDS = 300
|
||||
_EXECUTION_CAPABILITY_MAX_TTL_SECONDS = 1_800
|
||||
_EXECUTION_CAPABILITY_SAFETY_MARGIN_SECONDS = 5
|
||||
@@ -215,6 +220,181 @@ def _safe_candidate(input_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise ValueError("no_safe_check_mode_candidate")
|
||||
|
||||
|
||||
def _apply_ansible_playbook_trust_gate(
|
||||
candidate_input: dict[str, Any],
|
||||
trust_rows: list[Mapping[str, Any]],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Filter failed PlayBooks before they can claim another runtime apply."""
|
||||
|
||||
candidates = candidate_input.get("executor_candidates")
|
||||
if not isinstance(candidates, list):
|
||||
candidates = []
|
||||
trust_by_id = {
|
||||
str(row.get("playbook_id") or ""): row
|
||||
for row in trust_rows
|
||||
if str(row.get("playbook_id") or "")
|
||||
}
|
||||
eligible: list[dict[str, Any]] = []
|
||||
decisions: list[dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
catalog_id = str(candidate.get("catalog_id") or "")
|
||||
canonical_id = canonical_ansible_playbook_id(catalog_id)
|
||||
row = trust_by_id.get(canonical_id)
|
||||
status = str((row or {}).get("status") or "").strip().lower()
|
||||
trust_score = float((row or {}).get("trust_score") or 0.0)
|
||||
success_count = int((row or {}).get("success_count") or 0)
|
||||
failure_count = int((row or {}).get("failure_count") or 0)
|
||||
retired = status in {"deprecated", "archived"}
|
||||
learned_failure = bool(
|
||||
row
|
||||
and failure_count > success_count
|
||||
and trust_score < TRUST_ARCHIVE_THRESHOLD
|
||||
)
|
||||
circuit_open = retired or learned_failure
|
||||
reason = (
|
||||
"playbook_status_retired"
|
||||
if retired
|
||||
else "playbook_trust_below_archive_threshold"
|
||||
if learned_failure
|
||||
else "playbook_trust_allows_controlled_canary"
|
||||
if row
|
||||
else "playbook_unobserved_canary_allowed"
|
||||
)
|
||||
decisions.append(
|
||||
{
|
||||
"catalog_id": catalog_id,
|
||||
"canonical_playbook_id": canonical_id,
|
||||
"trust_record_present": row is not None,
|
||||
"status": status or None,
|
||||
"trust_score": round(trust_score, 6) if row else None,
|
||||
"success_count": success_count,
|
||||
"failure_count": failure_count,
|
||||
"review_required": bool((row or {}).get("review_required")),
|
||||
"circuit_open": circuit_open,
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
if not circuit_open:
|
||||
eligible.append(candidate)
|
||||
|
||||
blocked_count = sum(1 for item in decisions if item["circuit_open"])
|
||||
status = (
|
||||
"circuit_open"
|
||||
if decisions and not eligible
|
||||
else "degraded_candidates_filtered"
|
||||
if blocked_count
|
||||
else "closed"
|
||||
)
|
||||
gate = {
|
||||
"schema_version": "ansible_playbook_trust_claim_gate_v1",
|
||||
"policy_source": "playbook_evolver.TRUST_ARCHIVE_THRESHOLD",
|
||||
"trust_archive_threshold": TRUST_ARCHIVE_THRESHOLD,
|
||||
"status": status,
|
||||
"candidate_count": len(decisions),
|
||||
"eligible_candidate_count": len(eligible),
|
||||
"blocked_candidate_count": blocked_count,
|
||||
"owner_review_used_as_terminal": False,
|
||||
"next_controlled_action": (
|
||||
"generate_or_verify_playbook_repair_candidate"
|
||||
if status == "circuit_open"
|
||||
else "continue_with_highest_ranked_eligible_candidate"
|
||||
),
|
||||
"decisions": decisions,
|
||||
}
|
||||
return (
|
||||
{
|
||||
**candidate_input,
|
||||
"executor_candidates": eligible,
|
||||
"playbook_trust_gate": gate,
|
||||
},
|
||||
gate,
|
||||
)
|
||||
|
||||
|
||||
async def _guard_candidate_input_by_playbook_trust(
|
||||
db: Any,
|
||||
candidate_input: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
candidates = candidate_input.get("executor_candidates")
|
||||
canonical_ids = sorted(
|
||||
{
|
||||
canonical_ansible_playbook_id(str(candidate.get("catalog_id") or ""))
|
||||
for candidate in candidates or []
|
||||
if isinstance(candidate, dict) and candidate.get("catalog_id")
|
||||
}
|
||||
)
|
||||
trust_rows: list[Mapping[str, Any]] = []
|
||||
if canonical_ids:
|
||||
result = await db.execute(
|
||||
select(
|
||||
PlaybookRecord.playbook_id,
|
||||
PlaybookRecord.status,
|
||||
PlaybookRecord.trust_score,
|
||||
PlaybookRecord.success_count,
|
||||
PlaybookRecord.failure_count,
|
||||
PlaybookRecord.review_required,
|
||||
).where(PlaybookRecord.playbook_id.in_(canonical_ids))
|
||||
)
|
||||
trust_rows = list(result.mappings().all())
|
||||
return _apply_ansible_playbook_trust_gate(candidate_input, trust_rows)
|
||||
|
||||
|
||||
async def _revalidate_claim_playbook_trust(
|
||||
db: Any,
|
||||
claim: AnsibleCheckModeClaim,
|
||||
) -> bool:
|
||||
candidate_input = {
|
||||
**claim.input_payload,
|
||||
"executor_candidates": [
|
||||
{
|
||||
"catalog_id": claim.catalog_id,
|
||||
"playbook_path": claim.apply_playbook_path,
|
||||
"check_mode_playbook_path": claim.playbook_path,
|
||||
"inventory_hosts": list(claim.inventory_hosts),
|
||||
"risk_level": claim.risk_level,
|
||||
}
|
||||
],
|
||||
}
|
||||
_, gate = await _guard_candidate_input_by_playbook_trust(
|
||||
db,
|
||||
candidate_input,
|
||||
)
|
||||
claim.input_payload["playbook_trust_gate"] = gate
|
||||
if gate.get("status") != "circuit_open":
|
||||
return True
|
||||
claim.input_payload["controlled_apply_allowed"] = False
|
||||
claim.input_payload["controlled_apply_blocker"] = (
|
||||
"playbook_trust_circuit_open"
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
UPDATE automation_operation_log
|
||||
SET input = coalesce(input, '{}'::jsonb) || jsonb_build_object(
|
||||
'controlled_apply_allowed', false,
|
||||
'controlled_apply_blocker', 'playbook_trust_circuit_open',
|
||||
'playbook_trust_gate', CAST(:trust_gate AS jsonb)
|
||||
),
|
||||
dry_run_result = coalesce(
|
||||
dry_run_result,
|
||||
'{}'::jsonb
|
||||
) || jsonb_build_object(
|
||||
'controlled_apply_allowed', false,
|
||||
'controlled_apply_blocker', 'playbook_trust_circuit_open',
|
||||
'playbook_trust_gate', CAST(:trust_gate AS jsonb)
|
||||
)
|
||||
WHERE op_id = CAST(:op_id AS uuid)
|
||||
AND operation_type = 'ansible_check_mode_executed'
|
||||
"""),
|
||||
{
|
||||
"trust_gate": json.dumps(gate, ensure_ascii=False),
|
||||
"op_id": claim.op_id,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _allowed_controlled_apply_risks() -> set[str]:
|
||||
raw = str(settings.AWOOOP_ANSIBLE_CONTROLLED_APPLY_ALLOWED_RISK_LEVELS or "")
|
||||
return {item.strip().lower() for item in raw.split(",") if item.strip()}
|
||||
@@ -276,6 +456,7 @@ def build_ansible_check_mode_claim_input(
|
||||
"target_selector": candidate_input.get("target_selector") or {},
|
||||
"source_truth_diff": candidate_input.get("source_truth_diff") or {},
|
||||
"risk_policy_decision": candidate_input.get("risk_policy_decision") or {},
|
||||
"playbook_trust_gate": candidate_input.get("playbook_trust_gate") or {},
|
||||
"source_candidate_op_id": source_candidate_op_id,
|
||||
"catalog_id": safe["catalog_id"],
|
||||
"playbook_path": safe["playbook_path"],
|
||||
@@ -1641,34 +1822,14 @@ async def _record_runtime_stage_receipts(
|
||||
return False
|
||||
|
||||
|
||||
async def _record_retry_runtime_stage_receipt(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
async def _append_runtime_stage_receipts_to_apply(
|
||||
*,
|
||||
apply_op_id: str,
|
||||
replay_op_id: str,
|
||||
receipts: tuple[dict[str, Any], ...],
|
||||
project_id: str,
|
||||
) -> bool:
|
||||
"""Append the verified no-write retry terminal to the original apply run."""
|
||||
|
||||
receipt = _runtime_stage_receipt(
|
||||
claim,
|
||||
stage_id="retry_or_rollback",
|
||||
evidence_ref=f"automation_operation_log:{replay_op_id}:dry_run_result",
|
||||
detail={
|
||||
"failed_apply_op_id": apply_op_id,
|
||||
"retry_check_mode_op_id": replay_op_id,
|
||||
"terminal_type": (
|
||||
"no_write_replay_passed_waiting_repair_candidate"
|
||||
if result.returncode == 0
|
||||
else "no_write_replay_failed_waiting_playbook_or_transport_repair"
|
||||
),
|
||||
"check_mode_replay_performed": True,
|
||||
"check_mode_replay_returncode": result.returncode,
|
||||
"runtime_apply_executed": False,
|
||||
"rollback_performed": False,
|
||||
},
|
||||
)
|
||||
if not receipts:
|
||||
return False
|
||||
try:
|
||||
async with get_db_context(project_id) as db:
|
||||
updated = await db.execute(
|
||||
@@ -1679,40 +1840,327 @@ async def _record_retry_runtime_stage_receipt(
|
||||
'{runtime_stage_receipts}',
|
||||
coalesce(
|
||||
(
|
||||
SELECT jsonb_agg(existing_receipt)
|
||||
FROM jsonb_array_elements(
|
||||
coalesce(
|
||||
apply.input -> 'runtime_stage_receipts',
|
||||
'[]'::jsonb
|
||||
SELECT jsonb_agg(
|
||||
deduplicated.receipt
|
||||
ORDER BY deduplicated.stage_id
|
||||
)
|
||||
FROM (
|
||||
SELECT DISTINCT ON (
|
||||
merged.receipt ->> 'stage_id'
|
||||
)
|
||||
) AS existing_receipt
|
||||
WHERE existing_receipt ->> 'stage_id'
|
||||
<> 'retry_or_rollback'
|
||||
merged.receipt,
|
||||
merged.receipt ->> 'stage_id' AS stage_id
|
||||
FROM jsonb_array_elements(
|
||||
coalesce(
|
||||
apply.input -> 'runtime_stage_receipts',
|
||||
'[]'::jsonb
|
||||
) || CAST(:receipts AS jsonb)
|
||||
) WITH ORDINALITY AS merged(receipt, position)
|
||||
WHERE merged.receipt ->> 'stage_id' IS NOT NULL
|
||||
ORDER BY
|
||||
merged.receipt ->> 'stage_id',
|
||||
merged.position DESC
|
||||
) deduplicated
|
||||
),
|
||||
'[]'::jsonb
|
||||
) || jsonb_build_array(CAST(:receipt AS jsonb)),
|
||||
),
|
||||
true
|
||||
)
|
||||
WHERE apply.op_id = CAST(:apply_op_id AS uuid)
|
||||
AND apply.operation_type = 'ansible_apply_executed'
|
||||
"""),
|
||||
{
|
||||
"receipt": json.dumps(receipt, ensure_ascii=False),
|
||||
"receipts": json.dumps(receipts, ensure_ascii=False),
|
||||
"apply_op_id": apply_op_id,
|
||||
},
|
||||
)
|
||||
return bool(updated.rowcount)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ansible_retry_runtime_stage_receipt_write_failed",
|
||||
automation_run_id=_automation_run_id_for_claim(claim),
|
||||
"ansible_runtime_terminal_receipt_append_failed",
|
||||
apply_op_id=apply_op_id,
|
||||
replay_op_id=replay_op_id,
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _record_incident_terminal_disposition(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
*,
|
||||
apply_op_id: str,
|
||||
project_id: str,
|
||||
terminal_type: str,
|
||||
success_terminal: bool,
|
||||
retry_op_id: str | None = None,
|
||||
telegram_receipt_acknowledged: bool,
|
||||
) -> dict[str, Any] | None:
|
||||
automation_run_id = _automation_run_id_for_claim(claim)
|
||||
incident_status = "RESOLVED" if success_terminal else "MITIGATING"
|
||||
safe_next_action = (
|
||||
"keep_verified_repair_and_monitor_recurrence"
|
||||
if success_terminal
|
||||
else "queue_ai_playbook_or_transport_repair_candidate"
|
||||
)
|
||||
terminal_payload = {
|
||||
"schema_version": "ansible_incident_terminal_disposition_v1",
|
||||
"automation_run_id": automation_run_id,
|
||||
"apply_op_id": apply_op_id,
|
||||
"retry_op_id": retry_op_id,
|
||||
"terminal_type": terminal_type,
|
||||
"incident_status": incident_status,
|
||||
"incident_resolved": success_terminal,
|
||||
"no_write_terminal": not success_terminal,
|
||||
"safe_next_action": safe_next_action,
|
||||
"telegram_receipt_acknowledged": telegram_receipt_acknowledged,
|
||||
"raw_log_payload_stored": False,
|
||||
"secret_value_stored": False,
|
||||
}
|
||||
try:
|
||||
async with get_db_context(project_id) as db:
|
||||
updated = await db.execute(
|
||||
text("""
|
||||
UPDATE incidents
|
||||
SET status = CAST(:incident_status AS incidentstatus),
|
||||
outcome = CAST(
|
||||
coalesce(CAST(outcome AS jsonb), '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'automation_terminal',
|
||||
CAST(:terminal_payload AS jsonb)
|
||||
)
|
||||
AS json
|
||||
),
|
||||
resolved_at = CASE
|
||||
WHEN :success_terminal THEN NOW()
|
||||
ELSE resolved_at
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE incident_id = :incident_id
|
||||
AND project_id = :project_id
|
||||
AND (
|
||||
:success_terminal
|
||||
OR upper(status::text) NOT IN ('RESOLVED', 'CLOSED')
|
||||
)
|
||||
RETURNING
|
||||
status::text AS incident_status,
|
||||
updated_at,
|
||||
resolved_at,
|
||||
outcome
|
||||
"""),
|
||||
{
|
||||
"incident_status": incident_status,
|
||||
"terminal_payload": json.dumps(
|
||||
terminal_payload,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"success_terminal": success_terminal,
|
||||
"incident_id": claim.incident_id,
|
||||
"project_id": project_id,
|
||||
},
|
||||
)
|
||||
row = updated.mappings().one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
outcome = _json_loads(row.get("outcome"))
|
||||
readback = outcome.get("automation_terminal")
|
||||
if not isinstance(readback, Mapping):
|
||||
return None
|
||||
if (
|
||||
readback.get("automation_run_id") != automation_run_id
|
||||
or readback.get("apply_op_id") != apply_op_id
|
||||
or readback.get("terminal_type") != terminal_type
|
||||
):
|
||||
return None
|
||||
return {
|
||||
**terminal_payload,
|
||||
"incident_id": claim.incident_id,
|
||||
"incident_status": str(row.get("incident_status") or ""),
|
||||
"incident_row_version": (
|
||||
row["updated_at"].isoformat()
|
||||
if row.get("updated_at") is not None
|
||||
else None
|
||||
),
|
||||
"repository_write_acknowledged": True,
|
||||
"repository_readback_verified": True,
|
||||
"durable_write_acknowledged": True,
|
||||
"writer_source_sha": os.getenv(
|
||||
"AWOOOI_BUILD_COMMIT_SHA",
|
||||
"",
|
||||
).strip().lower()
|
||||
or None,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ansible_incident_terminal_disposition_write_failed",
|
||||
automation_run_id=automation_run_id,
|
||||
incident_id=claim.incident_id,
|
||||
apply_op_id=apply_op_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _record_retry_runtime_stage_receipt(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
*,
|
||||
apply_op_id: str,
|
||||
replay_op_id: str,
|
||||
project_id: str,
|
||||
) -> bool:
|
||||
"""Append the verified no-write retry terminal to the original apply run."""
|
||||
|
||||
automation_run_id = _automation_run_id_for_claim(claim)
|
||||
terminal_type = (
|
||||
"no_write_replay_passed_waiting_repair_candidate"
|
||||
if result.returncode == 0
|
||||
else "no_write_replay_failed_waiting_playbook_or_transport_repair"
|
||||
)
|
||||
retry_idempotency_key = f"ansible-retry:{apply_op_id}:check-mode"
|
||||
replay_readback_verified = False
|
||||
telegram_receipt_acknowledged = False
|
||||
try:
|
||||
async with get_db_context(project_id) as db:
|
||||
replay_readback = await db.execute(
|
||||
text("""
|
||||
SELECT status, input, output, dry_run_result
|
||||
FROM automation_operation_log
|
||||
WHERE op_id = CAST(:replay_op_id AS uuid)
|
||||
AND operation_type = 'ansible_execution_skipped'
|
||||
AND parent_op_id = CAST(:apply_op_id AS uuid)
|
||||
"""),
|
||||
{
|
||||
"replay_op_id": replay_op_id,
|
||||
"apply_op_id": apply_op_id,
|
||||
},
|
||||
)
|
||||
replay_row = replay_readback.mappings().one_or_none()
|
||||
replay_input = _json_loads(
|
||||
replay_row.get("input") if replay_row else None
|
||||
)
|
||||
replay_output = _json_loads(
|
||||
replay_row.get("output") if replay_row else None
|
||||
)
|
||||
replay_readback_verified = bool(
|
||||
replay_row
|
||||
and replay_row.get("status")
|
||||
== ("success" if result.returncode == 0 else "failed")
|
||||
and replay_input.get("automation_run_id")
|
||||
== automation_run_id
|
||||
and replay_input.get("retry_of_apply_op_id") == apply_op_id
|
||||
and replay_output.get("runtime_apply_executed") is False
|
||||
and replay_output.get("terminal_disposition") == terminal_type
|
||||
)
|
||||
telegram_readback = await db.execute(
|
||||
text("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM awooop_outbound_message
|
||||
WHERE project_id = :project_id
|
||||
AND channel_type = 'telegram'
|
||||
AND send_status = 'sent'
|
||||
AND source_envelope #>> '{callback_reply,action}'
|
||||
= 'controlled_apply_result'
|
||||
AND COALESCE(
|
||||
source_envelope ->> 'automation_run_id',
|
||||
source_envelope #>>
|
||||
'{callback_reply,automation_run_id}',
|
||||
source_envelope #>>
|
||||
'{source_refs,automation_run_ids,0}'
|
||||
) = :automation_run_id
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"project_id": project_id,
|
||||
"automation_run_id": automation_run_id,
|
||||
},
|
||||
)
|
||||
telegram_receipt_acknowledged = telegram_readback.scalar() is True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ansible_retry_terminal_readback_failed",
|
||||
automation_run_id=automation_run_id,
|
||||
apply_op_id=apply_op_id,
|
||||
replay_op_id=replay_op_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
|
||||
if not replay_readback_verified:
|
||||
logger.warning(
|
||||
"ansible_retry_terminal_receipt_not_written_unverified",
|
||||
automation_run_id=automation_run_id,
|
||||
apply_op_id=apply_op_id,
|
||||
replay_op_id=replay_op_id,
|
||||
)
|
||||
return False
|
||||
|
||||
retry_receipt = _runtime_stage_receipt(
|
||||
claim,
|
||||
stage_id="retry_or_rollback",
|
||||
evidence_ref=f"automation_operation_log:{replay_op_id}:dry_run_result",
|
||||
detail={
|
||||
"schema_version": "ansible_controlled_retry_terminal_v2",
|
||||
"failed_apply_op_id": apply_op_id,
|
||||
"retry_check_mode_op_id": replay_op_id,
|
||||
"terminal_type": terminal_type,
|
||||
"check_mode_replay_performed": True,
|
||||
"check_mode_replay_returncode": result.returncode,
|
||||
"runtime_apply_executed": False,
|
||||
"rollback_performed": False,
|
||||
"verified_no_write_terminal": replay_readback_verified,
|
||||
"retry_operation_readback_verified": replay_readback_verified,
|
||||
"retry_attempt": 1,
|
||||
"max_retry_attempts": _CONTROLLED_RETRY_MAX_ATTEMPTS,
|
||||
"retry_idempotency_key": retry_idempotency_key,
|
||||
"retry_error_backoff_min_seconds": (
|
||||
_CONTROLLED_RETRY_ERROR_BACKOFF_MIN_SECONDS
|
||||
),
|
||||
"retry_error_backoff_max_seconds": (
|
||||
_CONTROLLED_RETRY_ERROR_BACKOFF_MAX_SECONDS
|
||||
),
|
||||
"bounded_retry": True,
|
||||
"repair_candidate_required": True,
|
||||
"safe_next_action": (
|
||||
"queue_ai_playbook_or_transport_repair_candidate"
|
||||
),
|
||||
"repository_readback_verified": replay_readback_verified,
|
||||
"durable_write_acknowledged": replay_readback_verified,
|
||||
"writer_source_sha": os.getenv(
|
||||
"AWOOOI_BUILD_COMMIT_SHA",
|
||||
"",
|
||||
).strip().lower()
|
||||
or None,
|
||||
"raw_log_payload_stored": False,
|
||||
"secret_value_stored": False,
|
||||
},
|
||||
)
|
||||
incident_detail = await _record_incident_terminal_disposition(
|
||||
claim,
|
||||
apply_op_id=apply_op_id,
|
||||
project_id=project_id,
|
||||
terminal_type=terminal_type,
|
||||
success_terminal=False,
|
||||
retry_op_id=replay_op_id,
|
||||
telegram_receipt_acknowledged=telegram_receipt_acknowledged,
|
||||
)
|
||||
receipts = [retry_receipt]
|
||||
if incident_detail is not None:
|
||||
receipts.append(
|
||||
_runtime_stage_receipt(
|
||||
claim,
|
||||
stage_id="incident_closure",
|
||||
evidence_ref=(
|
||||
f"incidents:{claim.incident_id}:automation_terminal"
|
||||
),
|
||||
detail=incident_detail,
|
||||
)
|
||||
)
|
||||
return await _append_runtime_stage_receipts_to_apply(
|
||||
apply_op_id=apply_op_id,
|
||||
receipts=tuple(receipts),
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
async def _record_learning_writeback_receipt(
|
||||
claim: AnsibleCheckModeClaim,
|
||||
result: AnsibleRunResult,
|
||||
@@ -2750,6 +3198,18 @@ async def claim_pending_check_modes(
|
||||
for row in rows:
|
||||
source_op_id = str(row["op_id"])
|
||||
candidate_input = _json_loads(row["input"])
|
||||
candidate_input, trust_gate = await _guard_candidate_input_by_playbook_trust(
|
||||
db,
|
||||
candidate_input,
|
||||
)
|
||||
if trust_gate.get("status") == "circuit_open":
|
||||
await _insert_skipped_candidate(
|
||||
db,
|
||||
source_candidate_op_id=source_op_id,
|
||||
candidate_input=candidate_input,
|
||||
reason="playbook_trust_circuit_open",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
claim_input = build_ansible_check_mode_claim_input(
|
||||
source_candidate_op_id=source_op_id,
|
||||
@@ -3099,6 +3559,9 @@ async def _insert_skipped_candidate(
|
||||
candidate_input: dict[str, Any],
|
||||
reason: str,
|
||||
) -> None:
|
||||
trust_gate = candidate_input.get("playbook_trust_gate")
|
||||
if not isinstance(trust_gate, dict):
|
||||
trust_gate = {}
|
||||
input_payload = {
|
||||
"automation_run_id": str(source_candidate_op_id),
|
||||
"incident_id": _incident_id_from_payload(candidate_input),
|
||||
@@ -3110,6 +3573,7 @@ async def _insert_skipped_candidate(
|
||||
"apply_enabled": False,
|
||||
"source_candidate_op_id": source_candidate_op_id,
|
||||
"not_used_reason": reason,
|
||||
"playbook_trust_gate": trust_gate,
|
||||
}
|
||||
await db.execute(
|
||||
text("""
|
||||
@@ -3138,12 +3602,19 @@ async def _insert_skipped_candidate(
|
||||
"output": json.dumps({
|
||||
"not_used_reason": reason,
|
||||
"decision_effect": "skipped_before_runtime",
|
||||
"next_controlled_action": (
|
||||
"generate_or_verify_playbook_repair_candidate"
|
||||
if reason == "playbook_trust_circuit_open"
|
||||
else "repair_candidate_input_before_retry"
|
||||
),
|
||||
"playbook_trust_gate": trust_gate,
|
||||
}, ensure_ascii=False),
|
||||
"dry_run_result": json.dumps({
|
||||
"check_mode_executed": False,
|
||||
"apply_executed": False,
|
||||
"skipped": True,
|
||||
"reason": reason,
|
||||
"playbook_trust_gate": trust_gate,
|
||||
}, ensure_ascii=False),
|
||||
"parent_op_id": source_candidate_op_id,
|
||||
"tags": ["ansible", "check_mode", "skipped", "apply_locked"],
|
||||
@@ -3318,6 +3789,16 @@ async def run_controlled_apply_for_claim(
|
||||
idempotency_key=apply_idempotency_key,
|
||||
)
|
||||
return None
|
||||
if not await _revalidate_claim_playbook_trust(db, claim):
|
||||
logger.warning(
|
||||
"ansible_controlled_apply_blocked_by_playbook_trust",
|
||||
op_id=claim.op_id,
|
||||
source_candidate_op_id=claim.source_candidate_op_id,
|
||||
incident_id=claim.incident_id,
|
||||
catalog_id=claim.catalog_id,
|
||||
blocker="playbook_trust_circuit_open",
|
||||
)
|
||||
return None
|
||||
inserted = await db.execute(
|
||||
text("""
|
||||
INSERT INTO automation_operation_log (
|
||||
|
||||
110
apps/api/tests/test_awooop_ansible_trust_claim_gate.py
Normal file
110
apps/api/tests/test_awooop_ansible_trust_claim_gate.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from src.services.awooop_ansible_check_mode_service import (
|
||||
_apply_ansible_playbook_trust_gate,
|
||||
_revalidate_claim_playbook_trust,
|
||||
run_controlled_apply_for_claim,
|
||||
)
|
||||
|
||||
|
||||
def _candidate(catalog_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"catalog_id": catalog_id,
|
||||
"playbook_path": f"infra/ansible/playbooks/{catalog_id.split(':', 1)[1]}.yml",
|
||||
"inventory_hosts": ["host_188"],
|
||||
"risk_level": "medium",
|
||||
}
|
||||
|
||||
|
||||
def test_low_trust_failed_playbook_opens_claim_circuit() -> None:
|
||||
guarded, gate = _apply_ansible_playbook_trust_gate(
|
||||
{"executor_candidates": [_candidate("ansible:nginx-sync")]},
|
||||
[
|
||||
{
|
||||
"playbook_id": "PB-ANSIBLE-NGINX-SYNC",
|
||||
"status": "approved",
|
||||
"trust_score": 0.001417,
|
||||
"success_count": 0,
|
||||
"failure_count": 24,
|
||||
"review_required": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert guarded["executor_candidates"] == []
|
||||
assert gate["status"] == "circuit_open"
|
||||
assert gate["blocked_candidate_count"] == 1
|
||||
assert gate["decisions"][0]["reason"] == (
|
||||
"playbook_trust_below_archive_threshold"
|
||||
)
|
||||
assert gate["next_controlled_action"] == (
|
||||
"generate_or_verify_playbook_repair_candidate"
|
||||
)
|
||||
|
||||
|
||||
def test_low_trust_candidate_is_filtered_without_blocking_healthy_candidate() -> None:
|
||||
healthy = _candidate("ansible:188-momo-backup-user")
|
||||
guarded, gate = _apply_ansible_playbook_trust_gate(
|
||||
{
|
||||
"executor_candidates": [
|
||||
_candidate("ansible:nginx-sync"),
|
||||
healthy,
|
||||
]
|
||||
},
|
||||
[
|
||||
{
|
||||
"playbook_id": "PB-ANSIBLE-NGINX-SYNC",
|
||||
"status": "approved",
|
||||
"trust_score": 0.01,
|
||||
"success_count": 0,
|
||||
"failure_count": 15,
|
||||
"review_required": True,
|
||||
},
|
||||
{
|
||||
"playbook_id": "PB-ANSIBLE-188-MOMO-BACKUP-USER",
|
||||
"status": "approved",
|
||||
"trust_score": 0.65,
|
||||
"success_count": 7,
|
||||
"failure_count": 1,
|
||||
"review_required": False,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert guarded["executor_candidates"] == [healthy]
|
||||
assert gate["status"] == "degraded_candidates_filtered"
|
||||
assert gate["eligible_candidate_count"] == 1
|
||||
assert gate["blocked_candidate_count"] == 1
|
||||
|
||||
|
||||
def test_owner_review_flag_does_not_become_low_medium_high_terminal_gate() -> None:
|
||||
candidate = _candidate("ansible:188-momo-backup-user")
|
||||
guarded, gate = _apply_ansible_playbook_trust_gate(
|
||||
{"executor_candidates": [candidate]},
|
||||
[
|
||||
{
|
||||
"playbook_id": "PB-ANSIBLE-188-MOMO-BACKUP-USER",
|
||||
"status": "approved",
|
||||
"trust_score": 0.4,
|
||||
"success_count": 2,
|
||||
"failure_count": 1,
|
||||
"review_required": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert guarded["executor_candidates"] == [candidate]
|
||||
assert gate["status"] == "closed"
|
||||
assert gate["owner_review_used_as_terminal"] is False
|
||||
|
||||
|
||||
def test_controlled_apply_revalidates_trust_before_runtime_write() -> None:
|
||||
apply_source = inspect.getsource(run_controlled_apply_for_claim)
|
||||
revalidate_source = inspect.getsource(_revalidate_claim_playbook_trust)
|
||||
|
||||
assert "_revalidate_claim_playbook_trust" in apply_source
|
||||
assert "ansible_controlled_apply_blocked_by_playbook_trust" in apply_source
|
||||
assert "playbook_trust_circuit_open" in revalidate_source
|
||||
assert "UPDATE automation_operation_log" in revalidate_source
|
||||
@@ -21,6 +21,7 @@ from src.services.awooop_ansible_check_mode_service import (
|
||||
_EXECUTION_CAPABILITY_FALLBACK_OPERATION_TYPE,
|
||||
AnsibleCheckModeClaim,
|
||||
AnsibleRunResult,
|
||||
_append_runtime_stage_receipts_to_apply,
|
||||
_automation_operation_log_incident_id,
|
||||
_build_auto_repair_execution_receipt,
|
||||
_claim_from_apply_operation_row,
|
||||
@@ -2310,9 +2311,12 @@ def test_failed_apply_retry_replay_is_no_write_and_idempotent() -> None:
|
||||
assert "run_controlled_apply_for_claim" not in source
|
||||
|
||||
receipt_source = inspect.getsource(_record_retry_runtime_stage_receipt)
|
||||
append_source = inspect.getsource(_append_runtime_stage_receipts_to_apply)
|
||||
assert "_runtime_stage_receipt" in receipt_source
|
||||
assert 'stage_id="retry_or_rollback"' in receipt_source
|
||||
assert "jsonb_array_elements" in receipt_source
|
||||
assert "jsonb_array_elements" in append_source
|
||||
assert "ansible_controlled_retry_terminal_v2" in receipt_source
|
||||
assert "ansible_retry_terminal_receipt_not_written_unverified" in receipt_source
|
||||
assert "runtime_apply_executed" in receipt_source
|
||||
|
||||
|
||||
|
||||
58
apps/api/tests/test_telegram_polling_owner_contract.py
Normal file
58
apps/api/tests/test_telegram_polling_owner_contract.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _load_deployment(path: str) -> dict:
|
||||
deployment_documents = list(
|
||||
yaml.safe_load_all(
|
||||
(ROOT / path).read_text(encoding="utf-8")
|
||||
)
|
||||
)
|
||||
return next(
|
||||
document
|
||||
for document in deployment_documents
|
||||
if document.get("kind") == "Deployment"
|
||||
and document.get("metadata", {}).get("name") == "awoooi-api"
|
||||
)
|
||||
|
||||
|
||||
def _api_environment(deployment: dict) -> dict[str, str | None]:
|
||||
api_container = next(
|
||||
container
|
||||
for container in deployment["spec"]["template"]["spec"]["containers"]
|
||||
if container["name"] == "api"
|
||||
)
|
||||
return {item["name"]: item.get("value") for item in api_container["env"]}
|
||||
|
||||
|
||||
def test_production_api_does_not_compete_with_openclaw_polling_owner() -> None:
|
||||
config = yaml.safe_load(
|
||||
(ROOT / "k8s/awoooi-prod/04-configmap.yaml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
deployment = _load_deployment("k8s/awoooi-prod/06-deployment-api.yaml")
|
||||
main = (ROOT / "apps/api/src/main.py").read_text(encoding="utf-8")
|
||||
api_env = _api_environment(deployment)
|
||||
|
||||
assert config["data"]["TELEGRAM_ENABLE_POLLING"] == "false"
|
||||
assert api_env["TELEGRAM_ENABLE_POLLING"] == "false"
|
||||
assert "if settings.TELEGRAM_ENABLE_POLLING" in main
|
||||
assert 'reason="OpenClaw' in main
|
||||
|
||||
|
||||
def test_development_api_cannot_emit_legacy_telegram_heartbeats() -> None:
|
||||
config = yaml.safe_load(
|
||||
(ROOT / "k8s/awoooi-dev/02-configmap.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
deployment = _load_deployment("k8s/awoooi-dev/04-deployment-api.yaml")
|
||||
api_env = _api_environment(deployment)
|
||||
|
||||
assert config["data"]["TELEGRAM_ENABLE_POLLING"] == "false"
|
||||
assert api_env["TELEGRAM_ENABLE_POLLING"] == "false"
|
||||
# An explicit env value overrides the same key inherited from envFrom.
|
||||
# This also stops old images whose heartbeat gate only checks this token.
|
||||
assert api_env["OPENCLAW_TG_BOT_TOKEN"] == ""
|
||||
@@ -39,6 +39,13 @@ spec:
|
||||
- secretRef:
|
||||
name: awoooi-secrets
|
||||
env:
|
||||
# OpenClaw owns Telegram ingress/outbound monitoring. Keep these
|
||||
# explicit so legacy dev images cannot inherit the bot token from
|
||||
# the monolithic awoooi-secrets envFrom and emit heartbeat noise.
|
||||
- name: TELEGRAM_ENABLE_POLLING
|
||||
value: "false"
|
||||
- name: OPENCLAW_TG_BOT_TOKEN
|
||||
value: ""
|
||||
- name: PROMETHEUS_MULTIPROC_DIR
|
||||
value: "/tmp/awoooi-prometheus-multiproc"
|
||||
volumeMounts:
|
||||
|
||||
@@ -98,9 +98,10 @@ data:
|
||||
# ============================================================================
|
||||
SHADOW_MODE_ENABLED: "false"
|
||||
SHADOW_MODE_LOG_ONLY: "false"
|
||||
# 2026-04-03 ogt: 改為 true — clawbot-v5 已設 STANDBY_MODE,AWOOOI API 接管 Polling
|
||||
# Phase 22.6: 統帥需要直接在同一 Bot 與 OpenClaw/NemoClaw 雙 AI 對話
|
||||
TELEGRAM_ENABLE_POLLING: "true"
|
||||
# The former API-owned polling rule is superseded_by=global_product_governance_v2.
|
||||
# 188/OpenClaw owns getUpdates. A second poller receives Telegram 409 and
|
||||
# can consume commands before the Agent99 bridge records a receipt.
|
||||
TELEGRAM_ENABLE_POLLING: "false"
|
||||
|
||||
# ============================================================================
|
||||
# 2026-04-03 ogt: SRE 戰情室群組三頭政治 (Triumvirate ADR-053)
|
||||
|
||||
@@ -187,7 +187,8 @@ spec:
|
||||
- name: NEMOTRON_TIMEOUT_SECONDS
|
||||
value: "55"
|
||||
- name: TELEGRAM_ENABLE_POLLING
|
||||
value: "true"
|
||||
# 188/OpenClaw is the single getUpdates owner.
|
||||
value: "false"
|
||||
- name: OLLAMA_URL
|
||||
value: "http://192.168.0.110:11435" # 2026-05-25 Codex: GCP-A via 110 proxy; health cooldown protects noisy offline probes
|
||||
- name: OLLAMA_SECONDARY_URL
|
||||
|
||||
Reference in New Issue
Block a user