diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 875e29313..f9ca12fce 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -64,8 +64,8 @@ from src.api.v1 import incidents as incidents_v1 # Phase 6.4: Decision Proposal from src.api.v1 import iwooos as iwooos_v1 # IwoooS security governance API from src.api.v1 import knowledge as knowledge_v1 # KB Phase 1: Knowledge Base from src.api.v1 import learning as learning_v1 # Phase D-G P0: Learning API -from src.api.v1 import metrics as metrics_v1 # Phase 7: Gold Metrics (真實血脈) from src.api.v1 import mcp_control_plane as mcp_control_plane_v1 +from src.api.v1 import metrics as metrics_v1 # Phase 7: Gold Metrics (真實血脈) from src.api.v1 import monitoring as monitoring_v1 # 2026-04-03: 監控工具狀態 from src.api.v1 import notifications as notifications_v1 # 2026-04-10: 通知頻道狀態 from src.api.v1 import ( @@ -650,22 +650,24 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: logger.warning("approval_timeout_resolver_schedule_failed", error=str(e)) # T73: 已有完成證據但仍卡在 INVESTIGATING 的舊 incident 小批次收斂。 - # 僅處理 auto-repair success / approval EXECUTION_SUCCESS / approval EXPIRED, - # 不自動關閉 manual_required 或單純 APPROVED 事件。 + # API production pool 只有 serving 預算;實際 loop 由單一 standalone + # signal worker 擁有,避免 coroutine 被 budget gate 關閉後仍假性記錄 + # scheduled。reconciler 本身只處理強證據或已不再 firing 的事件。 try: from src.jobs.incident_lifecycle_reconciler import ( INTERVAL_SECONDS as INCIDENT_LIFECYCLE_RECONCILER_INTERVAL, ) - from src.jobs.incident_lifecycle_reconciler import ( - run_incident_lifecycle_reconciler_loop, - ) - schedule_api_background_task(run_incident_lifecycle_reconciler_loop()) logger.info( - "incident_lifecycle_reconciler_scheduled", + "incident_lifecycle_reconciler_owner_delegated", interval_sec=INCIDENT_LIFECYCLE_RECONCILER_INTERVAL, + scheduled_in_api_process=False, + production_process_owner="awoooi-worker", ) except Exception as e: - logger.warning("incident_lifecycle_reconciler_schedule_failed", error=str(e)) + logger.warning( + "incident_lifecycle_reconciler_owner_resolution_failed", + error=str(e), + ) # AwoooP Ansible candidate backfill worker. # 把近期已命中 allowlisted PlayBook、但缺 durable candidate row 的事故補進 diff --git a/apps/api/src/services/mcp_version_lifecycle_service.py b/apps/api/src/services/mcp_version_lifecycle_service.py index d1ecf328a..c4ed3a2c3 100644 --- a/apps/api/src/services/mcp_version_lifecycle_service.py +++ b/apps/api/src/services/mcp_version_lifecycle_service.py @@ -42,7 +42,7 @@ _SOURCE_HOSTS = { } _VERSION_PATTERNS = ( re.compile( - rb"""(?ix)["'](?:version|latestVersion|packageVersion)["']\s*[:=]\s*["']v?""" + rb"""(?ix)["'](?:latestVersion|packageVersion|softwareVersion)["']\s*[:=]\s*["']v?""" rb"""([0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?)["']""" ), re.compile( diff --git a/apps/api/src/services/telegram_alert_ai_automation_matrix.py b/apps/api/src/services/telegram_alert_ai_automation_matrix.py index 76a90e288..9e9c9fbd6 100644 --- a/apps/api/src/services/telegram_alert_ai_automation_matrix.py +++ b/apps/api/src/services/telegram_alert_ai_automation_matrix.py @@ -703,13 +703,14 @@ def _inspect_source_contract(repo_root: Path) -> dict[str, Any]: def _read_source_contract_text(repo_root: Path, relative_path: str) -> str: - candidates = [repo_root / relative_path] + source_path = repo_root / relative_path + candidates = [source_path] api_prefix = "apps/api/" if relative_path.startswith(api_prefix): candidates.append(repo_root / relative_path.removeprefix(api_prefix)) if ( relative_path == _AWOOOP_VERIFIED_CONTEXT_SURFACE_SOURCE - and not (repo_root / "apps").exists() + and not source_path.exists() ): candidates.append(repo_root / "src/services/telegram_alert_ai_automation_matrix.py") diff --git a/apps/api/src/workers/signal_worker.py b/apps/api/src/workers/signal_worker.py index 1a16c0a39..38c029333 100644 --- a/apps/api/src/workers/signal_worker.py +++ b/apps/api/src/workers/signal_worker.py @@ -623,7 +623,35 @@ async def _main() -> None: backup_restore_legacy_backfill_task: asyncio.Task[Any] | None = None security_maintenance_tasks: list[asyncio.Task[Any]] = [] agent99_controlled_dispatch_reconciler_task: asyncio.Task[Any] | None = None + incident_lifecycle_reconciler_task: asyncio.Task[Any] | None = None mcp_version_lifecycle_task: asyncio.Task[Any] | None = None + + # Production API pods reserve their DB pool for serving requests. This + # singleton worker therefore owns the existing bounded, fail-closed + # lifecycle reconciler. It resolves at most one configured batch per pass + # and independently reads current Prometheus firing state before treating + # inactive alerts as closure candidates. + from src.jobs.incident_lifecycle_reconciler import ( + BATCH_LIMIT as INCIDENT_LIFECYCLE_RECONCILER_BATCH_LIMIT, + ) + from src.jobs.incident_lifecycle_reconciler import ( + INTERVAL_SECONDS as INCIDENT_LIFECYCLE_RECONCILER_INTERVAL, + ) + from src.jobs.incident_lifecycle_reconciler import ( + run_incident_lifecycle_reconciler_loop, + ) + + incident_lifecycle_reconciler_task = asyncio.create_task( + run_incident_lifecycle_reconciler_loop(), + name="run_incident_lifecycle_reconciler_loop", + ) + logger.info( + "signal_worker_incident_lifecycle_reconciler_started", + owner="signal_worker", + interval_seconds=INCIDENT_LIFECYCLE_RECONCILER_INTERVAL, + batch_limit=INCIDENT_LIFECYCLE_RECONCILER_BATCH_LIMIT, + direct_incident_closure_performed=False, + ) if settings.ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER: from src.jobs.awooop_ansible_candidate_backfill_job import ( run_awooop_ansible_candidate_backfill_loop, @@ -794,6 +822,12 @@ async def _main() -> None: await agent99_controlled_dispatch_reconciler_task except asyncio.CancelledError: pass + if incident_lifecycle_reconciler_task is not None: + incident_lifecycle_reconciler_task.cancel() + try: + await incident_lifecycle_reconciler_task + except asyncio.CancelledError: + pass if mcp_version_lifecycle_task is not None: mcp_version_lifecycle_task.cancel() try: diff --git a/apps/api/tests/test_mcp_version_lifecycle_service.py b/apps/api/tests/test_mcp_version_lifecycle_service.py index 17c3f77de..15fc35bd4 100644 --- a/apps/api/tests/test_mcp_version_lifecycle_service.py +++ b/apps/api/tests/test_mcp_version_lifecycle_service.py @@ -90,7 +90,7 @@ async def test_lifecycle_run_persists_baselines_and_fail_closed_terminals() -> N status="ok", source_url=url, http_status=200, - body=b'{"version":"1.2.3","content":"not persisted"}', + body=b'{"packageVersion":"1.2.3","content":"not persisted"}', ) result = await run_mcp_version_lifecycle_once( @@ -127,7 +127,7 @@ async def test_lifecycle_run_persists_baselines_and_fail_closed_terminals() -> N def test_changed_version_stops_before_replay_without_immutable_receipts() -> None: - old_body = b'{"version":"1.2.3"}' + old_body = b'{"packageVersion":"1.2.3"}' candidate = _catalog()["capabilities"][0] observation = build_candidate_observation( candidate, @@ -135,7 +135,7 @@ def test_changed_version_stops_before_replay_without_immutable_receipts() -> Non status="ok", source_url=candidate["catalog_url"], http_status=200, - body=b'{"version":"1.3.0"}', + body=b'{"packageVersion":"1.3.0"}', ), previous={ "observed_version": "1.2.3", @@ -201,13 +201,20 @@ async def test_source_failure_is_durable_partial_degraded_no_write() -> None: def test_version_parser_rejects_ambiguous_page_versions() -> None: - assert _extract_unambiguous_version(b'{"version":"1.2.3"}') == "1.2.3" + assert _extract_unambiguous_version(b'{"packageVersion":"1.2.3"}') == "1.2.3" assert ( - _extract_unambiguous_version(b'{"version":"1.2.3","latestVersion":"2.0.0"}') + _extract_unambiguous_version( + b'{"packageVersion":"1.2.3","latestVersion":"2.0.0"}' + ) is None ) +def test_version_parser_ignores_cloudflare_beacon_version() -> None: + body = b"data-cf-beacon='{\"version\":\"2024.11.0\",\"token\":\"public\"}'" + assert _extract_unambiguous_version(body) is None + + def test_stable_fingerprint_ignores_randomized_recommendation_sections() -> None: stable_head = b"""