diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1
index 69d52d61a..e16a0a0f7 100644
--- a/agent99-control-plane.ps1
+++ b/agent99-control-plane.ps1
@@ -1697,6 +1697,20 @@ function Invoke-AgentSensorGateSelfTest {
exit 0
}
+function Invoke-AgentCanonicalLifecycleIngress {
+ param(
+ [string]$Url,
+ [string]$Token,
+ [object]$Payload,
+ [int]$TimeoutSeconds
+ )
+
+ Invoke-RestMethod -Method Post -Uri $Url -Headers @{
+ "X-Agent99-Lifecycle-Token" = $Token
+ "X-Project-ID" = "awoooi"
+ } -ContentType "application/json; charset=utf-8" -Body ($Payload | ConvertTo-Json -Depth 8 -Compress) -TimeoutSec $TimeoutSeconds
+}
+
function Send-AgentTelegram {
param(
[string]$Severity,
@@ -1707,6 +1721,10 @@ function Send-AgentTelegram {
$telegram = $Config.telegram
$enabled = ($telegram -and $telegram.enabled)
+ $gateway = if ($telegram -and $telegram.PSObject.Properties["canonicalGateway"]) { $telegram.canonicalGateway } else { $null }
+ $gatewayUrl = if ($gateway -and $gateway.PSObject.Properties["url"]) { [string]$gateway.url } else { "" }
+ $tokenEnv = if ($gateway -and $gateway.PSObject.Properties["tokenEnv"]) { [string]$gateway.tokenEnv } else { "AGENT99_SRE_RELAY_TOKEN" }
+ $timeoutSeconds = if ($gateway -and $gateway.PSObject.Properties["timeoutSeconds"]) { [math]::Min(30, [math]::Max(5, [int]$gateway.timeoutSeconds)) } else { 20 }
$chatIdOverride = if ($Data -and $Data.PSObject.Properties["replyChatId"]) { [string]$Data.replyChatId } else { "" }
$card = Get-AgentIncidentCardModel $Severity $EventType $Message $Data
$incidentState = Get-AgentTelegramIncidentState $card
@@ -1714,31 +1732,37 @@ function Send-AgentTelegram {
$storedRootMessageId = if ($incidentState.value -and $incidentState.value.PSObject.Properties["rootMessageId"]) { [string]$incidentState.value.rootMessageId } else { "" }
$replyToMessageId = if ($explicitReplyMessageId -match "^\d+$") { $explicitReplyMessageId } elseif ($storedRootMessageId -match "^\d+$") { $storedRootMessageId } else { "" }
- $attempt = [pscustomobject]@{
- timestamp = (Get-Date -Format o)
- enabled = [bool]$enabled
- eventType = $EventType
- severity = $Severity
- messageFormat = "incident_card_zh_tw_v5"
- incidentId = $card.incidentId
- fingerprint = $card.fingerprint
- lifecycle = $card.lifecycle
- stateKey = $card.stateKey
- configured = $false
- tokenEnv = $null
- chatIdEnv = $null
- chatOverride = if ($chatIdOverride) { "telegram-origin" } else { $null }
- replyToMessageId = if ($replyToMessageId) { $replyToMessageId } else { $null }
- messageId = $null
- incidentStatePath = $incidentState.path
- incidentStateWritten = $false
- visualPath = $null
- visualSent = $false
- visualError = $null
- relay = $null
- sent = $false
- error = $null
- }
+ $attempt = [pscustomobject]@{
+ timestamp = (Get-Date -Format o)
+ enabled = [bool]$enabled
+ eventType = $EventType
+ severity = $Severity
+ messageFormat = "incident_card_zh_tw_v6"
+ incidentId = $card.incidentId
+ fingerprint = $card.fingerprint
+ lifecycle = $card.lifecycle
+ stateKey = $card.stateKey
+ deliveryId = $null
+ configured = [bool]$gatewayUrl
+ tokenEnv = $tokenEnv
+ tokenPresent = $false
+ chatIdEnv = $null
+ chatOverride = if ($chatIdOverride) { "telegram-origin" } else { $null }
+ replyToMessageId = if ($replyToMessageId) { $replyToMessageId } else { $null }
+ messageId = $null
+ incidentStatePath = $incidentState.path
+ incidentStateWritten = $false
+ visualPath = $null
+ visualSent = $false
+ visualError = $null
+ relay = $null
+ sent = $false
+ suppressed = $false
+ reason = $null
+ error = $null
+ rawResponseStored = $false
+ secretValueLogged = $false
+ }
if (-not $enabled) {
$attempt.error = "telegram_disabled"
@@ -1746,16 +1770,119 @@ function Send-AgentTelegram {
return $attempt
}
- # Fail closed before reading a sender binding, copying an attachment, or
- # attempting any provider call. Agent99 events must be routed by the
- # canonical API registry/gateway with a durable receipt.
- $attempt.error = "canonical_telegram_gateway_transport_required"
- $attempt.sent = $false
- $attempt.relay = [pscustomobject]@{
- ok = $false
- providerSendPerformed = $false
- routeStatus = "blocked_no_egress"
- error = "canonical_telegram_gateway_transport_required"
+ # A successful same-run dispatch is finalized by the API reconciler, which
+ # owns the terminal Telegram/KM/PlayBook bundle. Failed and scheduled events
+ # still use this lifecycle ingress so they cannot disappear silently.
+ $dispatchIdentity = Get-AgentObjectValue $Data "identity" $null
+ $outcome = Get-AgentObjectValue $Data "outcome" $null
+ $dispatchIdentitySchema = [string](Get-AgentObjectValue $dispatchIdentity "schemaVersion" (Get-AgentObjectValue $dispatchIdentity "schema_version" ""))
+ $reconcilerOwnsTerminal = [bool](
+ $dispatchIdentity -and
+ $dispatchIdentitySchema -eq "agent99_controlled_dispatch_identity_v1" -and
+ [string](Get-AgentObjectValue $outcome "state" "") -eq "resolved" -and
+ (Convert-AgentBool (Get-AgentObjectValue $outcome "verifierPassed" $false)) -and
+ (Convert-AgentBool (Get-AgentObjectValue $outcome "sourceEventResolved" $false))
+ )
+ if ($reconcilerOwnsTerminal) {
+ $attempt.suppressed = $true
+ $attempt.reason = "canonical_reconciler_owns_terminal_receipt"
+ $attempt.relay = [pscustomobject]@{
+ ok = $true
+ providerSendPerformed = $false
+ routeStatus = "delegated_to_verified_reconciler"
+ durableAck = $false
+ error = $null
+ }
+ $script:TelegramAttempts += $attempt
+ return $attempt
+ }
+
+ if ($gatewayUrl -notmatch '^https://awoooi\.wooo\.work/api/v1/agents/agent99/telegram-lifecycle$') {
+ $attempt.error = "canonical_gateway_url_invalid"
+ $script:TelegramAttempts += $attempt
+ return $attempt
+ }
+ $token = Get-AgentEnvironmentValue $tokenEnv
+ $attempt.tokenPresent = [bool]$token
+ if (-not $token) {
+ $attempt.error = "canonical_gateway_token_missing"
+ $script:TelegramAttempts += $attempt
+ return $attempt
+ }
+
+ $deliverySource = "$($card.incidentId)|$($card.fingerprint)|$($card.stateKey)|$($card.runKey)"
+ $deliveryHashBytes = [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($deliverySource))
+ $deliveryHash = [BitConverter]::ToString($deliveryHashBytes).Replace("-", "").ToLowerInvariant()
+ $deliveryId = "agent99-lifecycle-$($deliveryHash.Substring(0, 40))"
+ $attempt.deliveryId = $deliveryId
+ $payload = [ordered]@{
+ schema_version = "agent99_telegram_lifecycle_v1"
+ delivery_id = $deliveryId
+ project_id = "awoooi"
+ incident_id = [string]$card.incidentId
+ fingerprint = [string]$card.fingerprint
+ event_type = $EventType
+ severity = $Severity
+ lifecycle = if ($card.lifecycle -in @("detected", "investigating", "remediating", "verifying", "recovered", "blocked")) { [string]$card.lifecycle } else { "unknown" }
+ state_key = [string]$card.stateKey
+ run_id = if ($card.runKey) { [string]$card.runKey } else { $null }
+ title = [string]$card.title
+ target = [string]$card.target
+ impact = [string]$card.impact
+ action = [string]$card.action
+ verification = [string]$card.verification
+ duration_text = [string]$card.durationText
+ occurrences = [int]$card.occurrences
+ next_update = [string]$card.nextUpdate
+ reply_to_message_id = if ($replyToMessageId) { [int64]$replyToMessageId } else { $null }
+ occurred_at = (Get-Date -Format o)
+ }
+
+ try {
+ $response = Invoke-AgentCanonicalLifecycleIngress $gatewayUrl $token $payload $timeoutSeconds
+ $sameIdentity = [bool](
+ [string](Get-AgentObjectValue $response "delivery_id" "") -eq $deliveryId -and
+ [string](Get-AgentObjectValue $response "incident_id" "") -eq [string]$card.incidentId
+ )
+ $durableAck = Convert-AgentBool (Get-AgentObjectValue $response "durable_outbound_acknowledged" $false)
+ $destinationVerified = Convert-AgentBool (Get-AgentObjectValue $response "destination_binding_verified" $false)
+ $providerMessageId = [string](Get-AgentObjectValue $response "provider_message_id" "")
+ $deliveryOk = [bool](
+ (Convert-AgentBool (Get-AgentObjectValue $response "ok" $false)) -and
+ $sameIdentity -and
+ $durableAck -and
+ $destinationVerified -and
+ $providerMessageId
+ )
+ $attempt.messageId = if ($providerMessageId) { $providerMessageId } else { $null }
+ $attempt.sent = $deliveryOk
+ $attempt.error = if ($deliveryOk) { $null } else { "canonical_gateway_receipt_not_verified" }
+ $attempt.relay = [pscustomobject]@{
+ ok = $deliveryOk
+ providerSendPerformed = Convert-AgentBool (Get-AgentObjectValue $response "provider_send_performed" $false)
+ routeStatus = [string](Get-AgentObjectValue $response "delivery_status" "unknown")
+ durableAck = $durableAck
+ destinationBindingVerified = $destinationVerified
+ error = $attempt.error
+ }
+ if ($deliveryOk) {
+ $statePath = Save-AgentTelegramIncidentState $card $incidentState $providerMessageId $replyToMessageId
+ $attempt.incidentStatePath = $statePath
+ $attempt.incidentStateWritten = [bool]$statePath
+ }
+ } catch {
+ $failure = Get-AgentSafeHttpFailureReceipt $_
+ $attempt.error = if ($failure.errorCode) { [string]$failure.errorCode } else { "canonical_gateway_http_error" }
+ $attempt.relay = [pscustomobject]@{
+ ok = $false
+ providerSendPerformed = $false
+ routeStatus = "delivery_failed"
+ durableAck = $false
+ destinationBindingVerified = $false
+ httpStatus = $failure.httpStatus
+ retryable = $failure.retryable
+ error = $attempt.error
+ }
}
$script:TelegramAttempts += $attempt
return $attempt
@@ -3253,16 +3380,25 @@ function Test-AgentRecentTelegramDelivery {
continue
}
if (-not $data) { continue }
- $attempts = @($data.telegram)
+ $allAttempts = @($data.telegram)
+ $attempts = @($allAttempts | Where-Object {
+ -not ($_.PSObject.Properties["suppressed"] -and $_.suppressed -eq $true)
+ })
if ($attempts.Count -eq 0) { continue }
- $sent = @($attempts | Where-Object { $_.sent -eq $true -or ($_.relay -and $_.relay.ok -eq $true) })
+ $sent = @($attempts | Where-Object {
+ $_.sent -eq $true -and
+ $_.relay -and
+ $_.relay.ok -eq $true -and
+ $_.relay.durableAck -eq $true
+ })
$checks += [pscustomobject]@{
name = if ($contract.name) { [string]$contract.name } else { [string]$contract.pattern }
latestPath = $file.FullName
latestFile = $file.Name
ageMinutes = [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 2)
attempts = $attempts.Count
+ suppressed = $allAttempts.Count - $attempts.Count
sent = $sent.Count
ok = [bool]($sent.Count -gt 0)
}
diff --git a/agent99-deploy.ps1 b/agent99-deploy.ps1
index f336da167..3998fc0f4 100644
--- a/agent99-deploy.ps1
+++ b/agent99-deploy.ps1
@@ -507,6 +507,10 @@ try {
Add-DefaultProperty $config.completionCallback "batchLimit" 5
Add-DefaultProperty $config.completionCallback "retryAfterMinutes" 5
Add-DefaultProperty $config "telegram" ([pscustomobject]@{})
+ Add-DefaultProperty $config.telegram "canonicalGateway" ([pscustomobject]@{})
+ Add-DefaultProperty $config.telegram.canonicalGateway "url" "https://awoooi.wooo.work/api/v1/agents/agent99/telegram-lifecycle"
+ Add-DefaultProperty $config.telegram.canonicalGateway "tokenEnv" "AGENT99_SRE_RELAY_TOKEN"
+ Add-DefaultProperty $config.telegram.canonicalGateway "timeoutSeconds" 20
Add-DefaultProperty $config.telegram "repeatAfterMinutes" 120
Add-DefaultProperty $config "performance" ([pscustomobject]@{})
Add-DefaultProperty $config.performance "processCpuHostPercentWarning" 25
diff --git a/agent99.config.99.example.json b/agent99.config.99.example.json
index 7474b5fb3..92980ff42 100644
--- a/agent99.config.99.example.json
+++ b/agent99.config.99.example.json
@@ -193,6 +193,11 @@
},
"telegram": {
"enabled": true,
+ "canonicalGateway": {
+ "url": "https://awoooi.wooo.work/api/v1/agents/agent99/telegram-lifecycle",
+ "tokenEnv": "AGENT99_SRE_RELAY_TOKEN",
+ "timeoutSeconds": 20
+ },
"botTokenEnv": "AGENT99_TELEGRAM_BOT_TOKEN",
"chatIdEnv": "AGENT99_TELEGRAM_CHAT_ID",
"botTokenEnvAliases": [
diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py
index 7f5187f71..0a08b9f92 100644
--- a/apps/api/src/api/v1/agents.py
+++ b/apps/api/src/api/v1/agents.py
@@ -38,13 +38,19 @@ from src.core.config import settings
from src.core.context import clear_project_context, set_project_context
from src.core.logging import get_logger
from src.core.sse import get_publisher
-from src.models.agent99_completion import Agent99CompletionCallbackRequest
+from src.models.agent99_completion import (
+ Agent99CompletionCallbackRequest,
+ Agent99TelegramLifecycleRequest,
+)
from src.services.agent99_completion_callback import (
record_agent99_completion_callback,
)
from src.services.agent99_enterprise_ai_automation_work_items import (
load_latest_agent99_enterprise_ai_automation_work_items,
)
+from src.services.agent99_telegram_lifecycle import (
+ deliver_agent99_telegram_lifecycle,
+)
from src.services.agent_market_governance_snapshot import (
load_latest_agent_market_governance_snapshot,
)
@@ -1187,6 +1193,58 @@ async def get_agent99_enterprise_ai_automation_work_items() -> dict[str, Any]:
) from exc
+@router.post(
+ "/agent99/telegram-lifecycle",
+ response_model=dict[str, Any],
+ summary="接收 Agent99 結構化生命週期事件",
+ description=(
+ "以 Agent99 relay token 驗證 Windows 99 結構化事件卡,交由 AWOOOI "
+ "canonical Telegram gateway 以 send-once outbox 傳送並回讀 durable ack。"
+ "此端點不接受 Bot token、任意訊息文字、raw log 或檔案內容。"
+ ),
+)
+async def post_agent99_telegram_lifecycle(
+ payload: Agent99TelegramLifecycleRequest,
+ x_agent99_lifecycle_token: str | None = Header(
+ default=None,
+ alias="X-Agent99-Lifecycle-Token",
+ ),
+) -> dict[str, Any]:
+ expected_token = settings.AGENT99_SRE_ALERT_RELAY_TOKEN
+ if not expected_token:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="agent99_lifecycle_auth_not_configured",
+ )
+ if not x_agent99_lifecycle_token or not hmac.compare_digest(
+ x_agent99_lifecycle_token,
+ expected_token,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="agent99_lifecycle_auth_failed",
+ )
+
+ context_tokens = set_project_context(
+ project_id=payload.project_id,
+ source="agent99_telegram_lifecycle",
+ request_id=payload.delivery_id,
+ )
+ try:
+ receipt = await deliver_agent99_telegram_lifecycle(payload)
+ finally:
+ clear_project_context(context_tokens)
+ if receipt.get("ok") is not True:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=(
+ "agent99_lifecycle_delivery_not_durably_acknowledged:"
+ f"{receipt.get('delivery_status') or 'unknown'}"
+ ),
+ )
+ return receipt
+
+
@router.post(
"/agent99/completion-callback",
response_model=dict[str, Any],
diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py
index 8a310cbaf..2aec50780 100644
--- a/apps/api/src/core/config.py
+++ b/apps/api/src/core/config.py
@@ -298,6 +298,16 @@ class Settings(BaseSettings):
"processes that share a constrained production role."
),
)
+ DATABASE_NULL_POOL_CONCURRENCY_LIMIT: int = Field(
+ default=0,
+ ge=0,
+ le=50,
+ description=(
+ "Per-process concurrent DB session cap when NullPool is enabled. "
+ "Zero leaves concurrency unchanged; bounded workers set this to "
+ "their verified role budget."
+ ),
+ )
DATABASE_BOOTSTRAP_DDL_ENABLED: bool = Field(
default=True,
description=(
diff --git a/apps/api/src/db/base.py b/apps/api/src/db/base.py
index 652f310b5..684405284 100644
--- a/apps/api/src/db/base.py
+++ b/apps/api/src/db/base.py
@@ -50,9 +50,54 @@ class Base(DeclarativeBase):
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
+_null_pool_session_limiter: asyncio.BoundedSemaphore | None = None
+_null_pool_session_limiter_size = 0
logger = get_logger("awoooi.db")
+def _get_null_pool_session_limiter() -> asyncio.BoundedSemaphore | None:
+ """Honor the configured connection budget even when NullPool is active."""
+
+ global _null_pool_session_limiter, _null_pool_session_limiter_size
+ limit = int(settings.DATABASE_NULL_POOL_CONCURRENCY_LIMIT)
+ if not settings.DATABASE_NULL_POOL or limit <= 0:
+ return None
+ if _null_pool_session_limiter is None or _null_pool_session_limiter_size != limit:
+ _null_pool_session_limiter = asyncio.BoundedSemaphore(limit)
+ _null_pool_session_limiter_size = limit
+ return _null_pool_session_limiter
+
+
+@asynccontextmanager
+async def _database_session_slot() -> AsyncGenerator[None, None]:
+ """Bound concurrent NullPool sessions without retaining DB connections."""
+
+ limiter = _get_null_pool_session_limiter()
+ if limiter is None:
+ yield
+ return
+ try:
+ await asyncio.wait_for(
+ limiter.acquire(),
+ timeout=settings.DATABASE_POOL_TIMEOUT_SECONDS,
+ )
+ except TimeoutError as exc:
+ logger.warning(
+ "database_null_pool_session_concurrency_limit_reached",
+ concurrency_limit=int(
+ settings.DATABASE_NULL_POOL_CONCURRENCY_LIMIT
+ ),
+ timeout_seconds=settings.DATABASE_POOL_TIMEOUT_SECONDS,
+ )
+ raise SQLAlchemyTimeoutError(
+ "NullPool database session concurrency limit reached"
+ ) from exc
+ try:
+ yield
+ finally:
+ limiter.release()
+
+
def _raise_unauthorized_db_context(msg: str) -> None:
context = get_current_project_context()
logger.error(
@@ -133,27 +178,28 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
...
"""
factory = get_session_factory()
- async with factory() as session:
- try:
- from src.core.context import get_current_project_id
+ async with _database_session_slot():
+ async with factory() as session:
+ try:
+ from src.core.context import get_current_project_id
- # AwoooP Phase 2.3 (2026-05-04 ogt): SET LOCAL app.project_id 讓 RLS Policy 生效
- # Fail-Closed RLS: 遇到未授權情境拋出錯誤而非回退到 "awoooi"
- pid = get_current_project_id()
- if not pid:
- _raise_unauthorized_db_context(
- "Unauthorized: project_id is missing in context (Fail-Closed RLS)"
+ # AwoooP Phase 2.3 (2026-05-04 ogt): SET LOCAL app.project_id 讓 RLS Policy 生效
+ # Fail-Closed RLS: 遇到未授權情境拋出錯誤而非回退到 "awoooi"
+ pid = get_current_project_id()
+ if not pid:
+ _raise_unauthorized_db_context(
+ "Unauthorized: project_id is missing in context (Fail-Closed RLS)"
+ )
+
+ await session.execute(
+ text("SELECT set_config('app.project_id', :pid, TRUE)"),
+ {"pid": pid},
)
-
- await session.execute(
- text("SELECT set_config('app.project_id', :pid, TRUE)"),
- {"pid": pid},
- )
- yield session
- await session.commit()
- except Exception:
- await session.rollback()
- raise
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
@asynccontextmanager
@@ -178,17 +224,18 @@ async def get_db_context(project_id: str | None = None) -> AsyncGenerator[AsyncS
_raise_unauthorized_db_context("Unauthorized: project_id is missing in context (Fail-Closed RLS)")
factory = get_session_factory()
- async with factory() as session:
- try:
- await session.execute(
- text("SELECT set_config('app.project_id', :pid, TRUE)"),
- {"pid": effective_pid},
- )
- yield session
- await session.commit()
- except Exception:
- await session.rollback()
- raise
+ async with _database_session_slot():
+ async with factory() as session:
+ try:
+ await session.execute(
+ text("SELECT set_config('app.project_id', :pid, TRUE)"),
+ {"pid": effective_pid},
+ )
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
# =============================================================================
diff --git a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py
index 55ae37c7a..728d64997 100644
--- a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py
+++ b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import hashlib
from collections.abc import Awaitable, Callable
+from datetime import UTC, datetime
from typing import Any
import structlog
@@ -54,6 +55,25 @@ LegacyIncidentFetcher = Callable[..., Awaitable[list[dict[str, Any]]]]
Agent99Dispatcher = Callable[..., Awaitable[dict[str, Any]]]
+def _dispatch_timeout_elapsed(value: Any) -> bool:
+ if not isinstance(value, datetime):
+ return False
+ normalized = value if value.tzinfo is not None else value.replace(tzinfo=UTC)
+ return normalized <= datetime.now(UTC)
+
+
+async def _terminalize_expired_outcome(
+ ledger: Any,
+ item: dict[str, Any],
+) -> bool:
+ if not _dispatch_timeout_elapsed(item.get("timeout_at")):
+ return False
+ terminal = await ledger.record_outcome_timeout_no_write_terminal(
+ identity=item["identity"],
+ )
+ return terminal.get("receipt_persisted") is True
+
+
def _is_bound_no_write_status_outcome(
outcome: dict[str, Any] | None,
*,
@@ -594,6 +614,7 @@ async def reconcile_agent99_controlled_dispatches_once(
"status_reconcile_requested": 0,
"status_reconcile_pending": 0,
"external_no_write_reconciled": 0,
+ "outcome_timeout_terminalized": 0,
"verifier_written": 0,
"closed": 0,
}
@@ -622,7 +643,10 @@ async def reconcile_agent99_controlled_dispatches_once(
source_receipt.get("resolved") is not True
or not source_receipt_ref
):
- counters["source_not_resolved"] += 1
+ if await _terminalize_expired_outcome(ledger, item):
+ counters["outcome_timeout_terminalized"] += 1
+ else:
+ counters["source_not_resolved"] += 1
continue
outcome = await asyncio.to_thread(
read_agent99_sre_outcome,
@@ -633,6 +657,9 @@ async def reconcile_agent99_controlled_dispatches_once(
identity=identity,
source_receipt_ref=source_receipt_ref,
):
+ if await _terminalize_expired_outcome(ledger, item):
+ counters["outcome_timeout_terminalized"] += 1
+ continue
request_receipt = await asyncio.to_thread(
request_agent99_same_run_status_reconcile,
identity,
@@ -674,6 +701,8 @@ async def reconcile_agent99_controlled_dispatches_once(
str(identity.run_id),
)
if not isinstance(outcome, dict):
+ if await _terminalize_expired_outcome(ledger, item):
+ counters["outcome_timeout_terminalized"] += 1
continue
mode = str(outcome.get("mode") or mode)
outcome_contract = (
@@ -707,6 +736,8 @@ async def reconcile_agent99_controlled_dispatches_once(
evidence_refs=evidence_refs,
)
if verifier.get("receipt_persisted") is not True:
+ if await _terminalize_expired_outcome(ledger, item):
+ counters["outcome_timeout_terminalized"] += 1
continue
counters["verifier_written"] += 1
receipt = verifier
@@ -735,7 +766,20 @@ async def run_agent99_controlled_dispatch_reconciler_loop() -> None:
error=str(exc),
)
try:
- await reconcile_agent99_controlled_dispatches_once()
+ reconciled = await reconcile_agent99_controlled_dispatches_once()
+ if any(
+ reconciled[key] > 0
+ for key in (
+ "external_no_write_reconciled",
+ "outcome_timeout_terminalized",
+ "verifier_written",
+ "closed",
+ )
+ ):
+ logger.info(
+ "agent99_controlled_dispatch_reconciler_tick",
+ **reconciled,
+ )
except asyncio.CancelledError:
raise
except Exception as exc:
diff --git a/apps/api/src/jobs/asset_capability_reconciliation_job.py b/apps/api/src/jobs/asset_capability_reconciliation_job.py
index 33625980f..8073fbed3 100644
--- a/apps/api/src/jobs/asset_capability_reconciliation_job.py
+++ b/apps/api/src/jobs/asset_capability_reconciliation_job.py
@@ -33,6 +33,8 @@ logger = structlog.get_logger(__name__)
_FIRST_DELAY_SECONDS = 90
_LOOP_BACKOFF_SECONDS = 30 * 60
+_MAX_RECONCILIATION_CANDIDATES = 20_000
+_RETRYABLE_RESULT_STATUSES = {"blocked_safe_no_write", "degraded_no_write"}
_DAILY_TRIGGER_HOUR_TAIPEI = 3
_DAILY_TRIGGER_MINUTE_TAIPEI = 30
_TAIPEI = ZoneInfo("Asia/Taipei")
@@ -187,17 +189,30 @@ async def run_asset_capability_reconciliation_loop() -> None:
daily_trigger_minute_taipei=_DAILY_TRIGGER_MINUTE_TAIPEI,
)
await asyncio.sleep(_FIRST_DELAY_SECONDS)
+ triggered_by = "startup"
while True:
try:
- await reconcile_once()
+ result = await reconcile_once(triggered_by=triggered_by)
except Exception as exc:
logger.exception(
"asset_capability_reconciliation_loop_error",
error_type=type(exc).__name__,
)
await asyncio.sleep(_LOOP_BACKOFF_SECONDS)
+ triggered_by = "retry"
+ continue
+ if str(result.get("status") or "") in _RETRYABLE_RESULT_STATUSES:
+ logger.warning(
+ "asset_capability_reconciliation_retry_scheduled",
+ status=result.get("status"),
+ reason=result.get("reason"),
+ retry_seconds=_LOOP_BACKOFF_SECONDS,
+ )
+ await asyncio.sleep(_LOOP_BACKOFF_SECONDS)
+ triggered_by = "retry"
continue
await asyncio.sleep(_seconds_until_next_trigger())
+ triggered_by = "cron"
async def reconcile_once(
@@ -231,6 +246,19 @@ async def reconcile_once(
}
candidates = build_reconciliation_candidates(matrix)
+ if len(candidates) > _MAX_RECONCILIATION_CANDIDATES:
+ logger.error(
+ "asset_capability_reconciliation_candidate_bound_exceeded",
+ candidate_count=len(candidates),
+ max_candidate_count=_MAX_RECONCILIATION_CANDIDATES,
+ external_runtime_write_performed=False,
+ )
+ return {
+ "status": "blocked_safe_no_write",
+ "reason": "candidate_count_above_controlled_execution_bound",
+ "candidate_count": len(candidates),
+ "max_candidate_count": _MAX_RECONCILIATION_CANDIDATES,
+ }
receipt = await _persist_reconciliation(
matrix,
candidates,
@@ -284,6 +312,8 @@ async def _persist_reconciliation(
AND COALESCE(input ->> 'semantic_operation_type', operation_type)
= :semantic_operation_type
AND input ->> 'idempotency_key' = :idempotency_key
+ AND status = 'success'
+ AND output ->> 'closed' = 'true'
ORDER BY created_at DESC
LIMIT 1
"""
@@ -298,28 +328,38 @@ async def _persist_reconciliation(
if isinstance(prior, dict):
return prior
- for candidate_raw in candidates:
- candidate = dict(candidate_raw)
+ candidate_fingerprints = [
+ str(candidate.get("candidate_fingerprint") or "")
+ for candidate in candidates
+ ]
+ existing_fingerprints: set[str] = set()
+ if candidate_fingerprints:
existing = await db.execute(
text(
"""
- SELECT op_id::text
+ SELECT DISTINCT input ->> 'candidate_fingerprint'
FROM automation_operation_log
WHERE actor = :actor
AND COALESCE(input ->> 'semantic_operation_type', operation_type)
= :semantic_operation_type
- AND input ->> 'candidate_fingerprint' = :candidate_fingerprint
- ORDER BY created_at DESC
- LIMIT 1
+ AND input ->> 'candidate_fingerprint'
+ = ANY(CAST(:candidate_fingerprints AS text[]))
"""
),
{
"actor": RECONCILIATION_ACTOR,
"semantic_operation_type": RECONCILIATION_WORK_ITEM_OPERATION,
- "candidate_fingerprint": candidate["candidate_fingerprint"],
+ "candidate_fingerprints": candidate_fingerprints,
},
)
- if existing.scalar_one_or_none():
+ existing_fingerprints = {
+ str(row[0]) for row in existing.fetchall() if row[0]
+ }
+
+ insert_rows: list[dict[str, Any]] = []
+ for candidate_raw in candidates:
+ candidate = dict(candidate_raw)
+ if candidate["candidate_fingerprint"] in existing_fingerprints:
continue
input_payload = build_reconciliation_work_item_input(
candidate,
@@ -334,6 +374,29 @@ async def _persist_reconciliation(
"post_verifier": "pending_batch_readback",
"rollback_or_no_write_terminal": "no_external_runtime_write",
}
+ insert_rows.append(
+ {
+ "asset_id": candidate.get("asset_inventory_id"),
+ "discovery_run_id": discovery_run_id,
+ "actor": RECONCILIATION_ACTOR,
+ "input": _canonical_json(input_payload),
+ "output": _canonical_json(output_payload),
+ "dry_run_result": _canonical_json(
+ {
+ "would_create_work_item": True,
+ "external_runtime_write": False,
+ "candidate_fingerprint": candidate["candidate_fingerprint"],
+ }
+ ),
+ "tags": [
+ "asset_capability_reconciliation",
+ "work_item",
+ "aia_p0_006",
+ ],
+ }
+ )
+
+ if insert_rows:
await db.execute(
text(
"""
@@ -360,50 +423,37 @@ async def _persist_reconciliation(
)
"""
),
- {
- "asset_id": candidate.get("asset_inventory_id"),
- "discovery_run_id": discovery_run_id,
- "actor": RECONCILIATION_ACTOR,
- "input": _canonical_json(input_payload),
- "output": _canonical_json(output_payload),
- "dry_run_result": _canonical_json(
- {
- "would_create_work_item": True,
- "external_runtime_write": False,
- "candidate_fingerprint": candidate["candidate_fingerprint"],
- }
- ),
- "tags": [
- "asset_capability_reconciliation",
- "work_item",
- "aia_p0_006",
- ],
- },
+ insert_rows,
)
- created_count += 1
+ created_count = len(insert_rows)
- rows_result = await db.execute(
- text(
- """
- SELECT input ->> 'candidate_fingerprint' AS candidate_fingerprint
- FROM automation_operation_log
- WHERE actor = :actor
- AND COALESCE(input ->> 'semantic_operation_type', operation_type)
- = :semantic_operation_type
- """
- ),
- {
- "actor": RECONCILIATION_ACTOR,
- "semantic_operation_type": RECONCILIATION_WORK_ITEM_OPERATION,
- },
- )
- persisted_fingerprints = {
- str(row[0]) for row in rows_result.fetchall() if row[0]
- }
expected_fingerprints = {
str(candidate.get("candidate_fingerprint") or "")
for candidate in candidates
}
+ persisted_fingerprints: set[str] = set()
+ if candidate_fingerprints:
+ rows_result = await db.execute(
+ text(
+ """
+ SELECT DISTINCT input ->> 'candidate_fingerprint'
+ FROM automation_operation_log
+ WHERE actor = :actor
+ AND COALESCE(input ->> 'semantic_operation_type', operation_type)
+ = :semantic_operation_type
+ AND input ->> 'candidate_fingerprint'
+ = ANY(CAST(:candidate_fingerprints AS text[]))
+ """
+ ),
+ {
+ "actor": RECONCILIATION_ACTOR,
+ "semantic_operation_type": RECONCILIATION_WORK_ITEM_OPERATION,
+ "candidate_fingerprints": candidate_fingerprints,
+ },
+ )
+ persisted_fingerprints = {
+ str(row[0]) for row in rows_result.fetchall() if row[0]
+ }
repository_readback_count = len(expected_fingerprints & persisted_fingerprints)
summary_output = build_reconciliation_summary_payload(
matrix,
diff --git a/apps/api/src/main.py b/apps/api/src/main.py
index f9ca12fce..ed734b331 100644
--- a/apps/api/src/main.py
+++ b/apps/api/src/main.py
@@ -191,6 +191,7 @@ def _resolve_request_project_context(request: Request) -> tuple[str | None, str]
return None, "request.project_id.missing"
+
# =============================================================================
# Sentry SDK Initialization (Error Tracking - 補強 SignOz)
# Self-Hosted @ 192.168.0.110
@@ -274,6 +275,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# AwoooP Phase 2.4 (2026-05-04 ogt): 設定 startup handler 的 project_id context
# asyncio.create_task() 自動繼承父任務的 ContextVar → 31 個 background loop 全部標記為 awoooi
from src.core.context import PROJECT_ID
+
PROJECT_ID.set("awoooi")
# Startup
@@ -345,6 +347,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# ADR-015: MCP Provider 註冊 (DI 模式)
from src.plugins.mcp.providers import register_all_providers
+
register_all_providers()
logger.info("mcp_providers_registered")
@@ -352,6 +355,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-16 ogt + Claude Sonnet 4.6: 修復 sensors=0/0 根因 — init 從未在 startup 被呼叫
try:
from src.services.mcp_tool_registry import init_mcp_tool_registry
+
await init_mcp_tool_registry()
logger.info("mcp_tool_registry_initialized")
except Exception as e:
@@ -367,7 +371,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
)
logger.info("telegram_heartbeat_monitor_started", interval_minutes=30)
else:
- logger.warning("telegram_heartbeat_monitor_skipped", reason="OPENCLAW_TG_BOT_TOKEN not set")
+ logger.warning(
+ "telegram_heartbeat_monitor_skipped", reason="OPENCLAW_TG_BOT_TOKEN not set"
+ )
# Reboot Recovery: Warm-up Redis Working Memory from PostgreSQL
# 2026-04-05 ogt: 重開機後 Redis 清空,從 DB restore 未解決的 incidents
@@ -392,10 +398,12 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
async with get_db_context() as db:
result = await db.execute(
select(IncidentRecord).where(
- IncidentRecord.status.in_([
- IncidentStatus.INVESTIGATING,
- IncidentStatus.MITIGATING,
- ])
+ IncidentRecord.status.in_(
+ [
+ IncidentStatus.INVESTIGATING,
+ IncidentStatus.MITIGATING,
+ ]
+ )
)
)
records = result.scalars().all()
@@ -446,6 +454,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 必須在 embedding indexing 之前,確保 playbook 表有資料
try:
from src.services.playbook_seed_service import seed_playbooks_from_rules
+
schedule_api_background_task(seed_playbooks_from_rules())
logger.info("playbook_seed_scheduled")
except Exception as e:
@@ -456,6 +465,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3.5 AI 學習成果持久化
try:
from src.repositories.playbook_repository import get_playbook_repository
+
schedule_api_background_task(get_playbook_repository().backfill_redis_to_pg())
logger.info("playbook_pg_backfill_scheduled")
except Exception as e:
@@ -465,6 +475,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
from src.services.playbook_embedding_service import (
ensure_playbook_embeddings_indexed,
)
+
schedule_api_background_task(ensure_playbook_embeddings_indexed())
logger.info("playbook_embedding_indexing_scheduled")
except Exception as e:
@@ -486,6 +497,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# dedup TTL 10 分鐘過期後,ready decisions 就沒有補送機制 → 長期卡在 ready 無人審核
try:
from src.services.decision_manager import get_decision_manager
+
schedule_api_background_task(get_decision_manager().resend_stale_ready_tokens())
logger.info("stale_ready_tokens_resend_scheduled")
except Exception as e:
@@ -497,6 +509,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# Sweeper 定期掃描無 decision token 的 INVESTIGATING incidents → 背景觸發
try:
from src.jobs.incident_analysis_sweeper import run_incident_analysis_sweeper
+
schedule_api_background_task(run_incident_analysis_sweeper())
logger.info("incident_analysis_sweeper_scheduled", interval_sec=90)
except Exception as e:
@@ -507,6 +520,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 解開 8 張 0 writer 表的第一個 (asset_inventory / asset_discovery_run / asset_coverage_snapshot)
try:
from src.jobs.asset_scanner_job import run_asset_scanner_loop
+
schedule_api_background_task(run_asset_scanner_loop())
logger.info("asset_scanner_loop_scheduled", interval_sec=3600)
except Exception as e:
@@ -517,6 +531,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 解鎖 E3 Hermes 自動建規則: AI 需要 alert_rule_catalog 作為 baseline 才能提案修正
try:
from src.jobs.rule_catalog_sync_job import run_rule_catalog_sync_loop
+
schedule_api_background_task(run_rule_catalog_sync_loop())
logger.info("rule_catalog_sync_loop_scheduled", interval_sec=3600)
except Exception as e:
@@ -527,6 +542,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 解鎖: Phase 4 Holt-Winters 預測有歷史資料 / 容量趨勢分析
try:
from src.jobs.capacity_scanner_job import run_capacity_scanner_loop
+
schedule_api_background_task(run_capacity_scanner_loop())
logger.info("capacity_scanner_loop_scheduled", daily_trigger_hour_taipei=2)
except Exception as e:
@@ -537,6 +553,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# MVP: secret_rotated 真實檢查,其他 6 維占位 'unknown',後續 agent 補
try:
from src.jobs.compliance_scanner_job import run_compliance_scanner_loop
+
schedule_api_background_task(run_compliance_scanner_loop())
logger.info("compliance_scanner_loop_scheduled", daily_trigger_hour_taipei=3)
except Exception as e:
@@ -546,6 +563,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 消費 signals:aider:events stream → 建 incident + 寫 aider_events 表
try:
from src.workers.aider_event_processor import run_aider_event_processor_loop
+
logger.info("aider_event_processor: starting Redis stream consumer")
schedule_api_background_task(run_aider_event_processor_loop())
except Exception as e:
@@ -556,6 +574,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 依據: Prometheus targets / alert_rule_catalog labels / knowledge_entries 覆蓋
try:
from src.jobs.coverage_evaluator_job import run_coverage_evaluator_loop
+
schedule_api_background_task(run_coverage_evaluator_loop())
logger.info("coverage_evaluator_loop_scheduled", interval_sec=3600)
except Exception as e:
@@ -566,6 +585,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 解鎖 E3 Hermes: noise_rate > 0.5 的 rule 可被 AI 提案 deprecate
try:
from src.jobs.rule_stats_updater_job import run_rule_stats_updater_loop
+
schedule_api_background_task(run_rule_stats_updater_loop())
logger.info("rule_stats_updater_loop_scheduled", interval_sec=3600)
except Exception as e:
@@ -576,35 +596,26 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 解鎖: 資產變化歷史 (added/removed/lifecycle_changed),AI 可追蹤集群演進
try:
from src.jobs.asset_change_tracker_job import run_asset_change_tracker_loop
+
schedule_api_background_task(run_asset_change_tracker_loop())
logger.info("asset_change_tracker_loop_scheduled", interval_sec=3600)
except Exception as e:
logger.warning("asset_change_tracker_loop_schedule_failed", error=str(e))
- # AIA-P0-006: reconcile the declared all-asset catalog with live ADR-090
- # inventory and project added/removed/drift capability gaps as durable work items.
- try:
- from src.jobs.asset_capability_reconciliation_job import (
- run_asset_capability_reconciliation_loop,
- )
-
- schedule_api_background_task(run_asset_capability_reconciliation_loop())
- logger.info(
- "asset_capability_reconciliation_loop_scheduled",
- daily_trigger_hour_taipei=3,
- daily_trigger_minute_taipei=30,
- )
- except Exception as e:
- logger.warning(
- "asset_capability_reconciliation_loop_schedule_failed",
- error=str(e),
- )
+ # AIA-P0-006 has one runtime owner. Production API pods reserve their DB
+ # budget for requests, so the singleton signal worker owns reconciliation.
+ logger.info(
+ "asset_capability_reconciliation_owner_delegated",
+ owner="signal_worker",
+ reason="api_db_pool_reserved_for_serving_requests",
+ )
# ADR-090 § Hermes Rule Quality Advisor (2026-04-19 ogt + Claude Opus 4.7 Asia/Taipei)
# 每日 04:00 Taipei 分析 alert_rule_catalog.noise_rate,對高噪音規則推 Telegram 建議
# 統帥鐵律: AI 只推建議不自動改 review_status,人工決策 deprecate
try:
from src.jobs.hermes_rule_quality_job import run_hermes_rule_quality_loop
+
schedule_api_background_task(run_hermes_rule_quality_loop())
logger.info("hermes_rule_quality_loop_scheduled", daily_trigger_hour_taipei=4)
except Exception as e:
@@ -615,6 +626,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 高風險 host 寫 aol(capacity_recommendation) + Telegram 建議
try:
from src.jobs.capacity_forecaster_job import run_capacity_forecaster_loop
+
schedule_api_background_task(run_capacity_forecaster_loop())
logger.info("capacity_forecaster_loop_scheduled", daily_trigger_hour_taipei=5)
except Exception as e:
@@ -628,6 +640,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
run_monthly_report_loop,
run_weekly_report_loop,
)
+
schedule_api_background_task(run_daily_report_loop())
schedule_api_background_task(run_weekly_report_loop())
schedule_api_background_task(run_monthly_report_loop())
@@ -644,6 +657,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 確保 PENDING approval 超過 48h 後觸發 resolve_incident → KM 學習鏈閉環
try:
from src.jobs.approval_timeout_resolver import run_approval_timeout_resolver
+
schedule_api_background_task(run_approval_timeout_resolver())
logger.info("approval_timeout_resolver_scheduled", interval_sec=3600)
except Exception as e:
@@ -657,6 +671,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
from src.jobs.incident_lifecycle_reconciler import (
INTERVAL_SECONDS as INCIDENT_LIFECYCLE_RECONCILER_INTERVAL,
)
+
logger.info(
"incident_lifecycle_reconciler_owner_delegated",
interval_sec=INCIDENT_LIFECYCLE_RECONCILER_INTERVAL,
@@ -676,6 +691,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
from src.jobs.awooop_ansible_candidate_backfill_job import (
run_awooop_ansible_candidate_backfill_loop,
)
+
scheduled_task = schedule_api_background_task(
run_awooop_ansible_candidate_backfill_loop()
)
@@ -687,7 +703,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
interval_seconds=settings.AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS,
)
except Exception as e:
- logger.warning("awooop_ansible_candidate_backfill_worker_schedule_failed", error=str(e))
+ logger.warning(
+ "awooop_ansible_candidate_backfill_worker_schedule_failed", error=str(e)
+ )
# AwoooP Ansible check-mode worker.
# 先執行 ansible-playbook --check --diff 並回寫 automation_operation_log;
@@ -696,6 +714,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
from src.jobs.awooop_ansible_check_mode_job import (
run_awooop_ansible_check_mode_loop,
)
+
scheduled_task = schedule_api_background_task(
run_awooop_ansible_check_mode_loop()
)
@@ -713,6 +732,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 初始建立
try:
from src.services.playbook_evolver import run_evolver_loop
+
schedule_api_background_task(run_evolver_loop())
logger.info("evolver_loop_scheduled", interval_sec=86400)
except Exception as e:
@@ -723,18 +743,22 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
from src.jobs.playbook_generation_governance_job import (
run_playbook_generation_governance_loop,
)
+
schedule_api_background_task(run_playbook_generation_governance_loop())
logger.info(
"playbook_generation_governance_loop_scheduled",
interval_sec=settings.PLAYBOOK_DRAFT_GOVERNANCE_INTERVAL_SECONDS,
)
except Exception as e:
- logger.warning("playbook_generation_governance_loop_schedule_failed", error=str(e))
+ logger.warning(
+ "playbook_generation_governance_loop_schedule_failed", error=str(e)
+ )
# ADR-083 Phase 3: 知識遺忘 Job(每日)— 30d 未引用 KB entry 標記 archived
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 初始建立
try:
from src.jobs.knowledge_decay_job import run_knowledge_decay_loop
+
schedule_api_background_task(run_knowledge_decay_loop())
logger.info("knowledge_decay_loop_scheduled", interval_sec=86400)
except Exception as e:
@@ -745,6 +769,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# Feature Flag: ENABLE_KM_BACKFILL_RECONCILER=false 停用(回滾用)
try:
from src.jobs.km_backfill_reconciler_job import run_km_backfill_reconciler_loop
+
schedule_api_background_task(run_km_backfill_reconciler_loop())
logger.info("km_backfill_reconciler_loop_scheduled", interval_sec=300)
except Exception as e:
@@ -756,6 +781,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# ADR-091 Task T2
try:
from src.jobs.aol_to_catalog_writeback_job import run_aol_writeback_loop
+
schedule_api_background_task(run_aol_writeback_loop())
logger.info("aol_to_catalog_writeback_loop_scheduled", interval_sec=3600)
except Exception as e:
@@ -771,28 +797,48 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
import asyncio as _asyncio
from src.jobs.kb_rot_cleaner import get_kb_rot_cleaner
+
while True:
try:
now = now_taipei()
# 計算下次月初 3 點(台北時間)
if now.day == 1 and now.hour < 3:
- next_run = now.replace(hour=3, minute=0, second=0, microsecond=0)
+ next_run = now.replace(
+ hour=3, minute=0, second=0, microsecond=0
+ )
elif now.month == 12:
next_run = now.replace(
- year=now.year + 1, month=1, day=1,
- hour=3, minute=0, second=0, microsecond=0,
+ year=now.year + 1,
+ month=1,
+ day=1,
+ hour=3,
+ minute=0,
+ second=0,
+ microsecond=0,
)
else:
next_run = now.replace(
- month=now.month + 1, day=1,
- hour=3, minute=0, second=0, microsecond=0,
+ month=now.month + 1,
+ day=1,
+ hour=3,
+ minute=0,
+ second=0,
+ microsecond=0,
)
sleep_sec = (next_run - now).total_seconds()
- logger.info("kb_rot_cleaner_next_run", next_run=next_run.isoformat(), sleep_sec=int(sleep_sec))
+ logger.info(
+ "kb_rot_cleaner_next_run",
+ next_run=next_run.isoformat(),
+ sleep_sec=int(sleep_sec),
+ )
await _asyncio.sleep(sleep_sec)
try:
result = await get_kb_rot_cleaner().run()
- logger.info("kb_rot_cleaner_completed", stale_count=result.stale_count, total=result.total_scanned)
+ logger.info(
+ "kb_rot_cleaner_completed",
+ stale_count=result.stale_count,
+ total=result.total_scanned,
+ )
except Exception as _e:
logger.exception("kb_rot_cleaner_failed", error=str(_e))
except _asyncio.CancelledError:
@@ -810,6 +856,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 初始建立
try:
from src.services.finetune_exporter import run_finetune_export_loop
+
schedule_api_background_task(run_finetune_export_loop())
logger.info("finetune_export_loop_scheduled", interval_sec=604800)
except Exception as e:
@@ -821,6 +868,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 初始建立
try:
from src.services.proactive_inspector import run_proactive_inspector_loop
+
schedule_api_background_task(run_proactive_inspector_loop())
logger.info("proactive_inspector_loop_scheduled", interval_sec=300)
except Exception as e:
@@ -830,6 +878,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 6 初始建立
try:
from src.jobs.offline_replay_service import run_offline_replay_loop
+
schedule_api_background_task(run_offline_replay_loop())
logger.info("offline_replay_loop_scheduled", interval_sec=604800)
except Exception as e:
@@ -839,6 +888,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# MASTER §1.1:系統必須能感知自身故障,W-1 SLO + W-2 Telegram靜默 + W-3 飛輪成功率
try:
from src.jobs.ai_slo_watchdog_job import run_ai_slo_watchdog_loop
+
schedule_api_background_task(run_ai_slo_watchdog_loop())
logger.info("ai_slo_watchdog_scheduled", interval_sec=900)
except Exception as e:
@@ -848,6 +898,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# MASTER P2.2:trust_drift / knowledge_degradation / llm_hallucination / execution_blast_radius
try:
from src.services.governance_agent import run_governance_loop
+
schedule_api_background_task(run_governance_loop())
logger.info("governance_agent_scheduled", interval_sec=3600)
except Exception as e:
@@ -856,6 +907,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 2026-05-03 ogt + Claude Sonnet 4.6(亞太): GovernanceDispatcher Wave 2E(每 30s poll)
try:
from src.services.governance_dispatcher import run_governance_dispatcher_loop
+
schedule_api_background_task(run_governance_dispatcher_loop())
logger.info("governance_dispatcher_scheduled", interval_sec=30)
except Exception as e:
@@ -866,6 +918,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 只產生 REVIEW 草稿並停在 owner review,不直接批准或發布 KM。
try:
from src.jobs.hermes_kb_growth_worker import run_hermes_kb_growth_loop
+
schedule_api_background_task(run_hermes_kb_growth_loop())
logger.info("hermes_kb_growth_worker_scheduled", interval_sec=300)
except Exception as e:
@@ -893,6 +946,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
try:
from src.core.redis_client import get_redis
from src.services.failover_alerter import configure_alerter
+
configure_alerter(get_redis())
logger.info("failover_alerter_configured")
except Exception as _alerter_err:
@@ -909,8 +963,10 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 探測 5 Provider(ollama/ollama_local/gemini/claude/openclaw_nemo)版本
# 寫入 ai_provider_version_history;版本變更時 log warning,P3.2.3 alerter 後續整合
try:
+
async def _run_model_version_tracker_loop() -> None:
from src.services.model_version_tracker import get_model_version_tracker
+
tracker = get_model_version_tracker()
while True:
try:
@@ -924,7 +980,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
except asyncio.CancelledError:
break
except Exception as _loop_err:
- logger.exception("model_version_tracker_loop_error", error=str(_loop_err))
+ logger.exception(
+ "model_version_tracker_loop_error", error=str(_loop_err)
+ )
await asyncio.sleep(60) # 錯誤後 1 分鐘重試
schedule_api_background_task(_run_model_version_tracker_loop())
@@ -937,6 +995,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# Shadow mode:is_shadow=True,0 user-visible response,0 destructive tool call
try:
from src.workers.platform_worker import start_platform_worker
+
if api_background_tasks_enabled:
await start_platform_worker()
logger.info("platform_worker_started", mode="shadow")
@@ -955,6 +1014,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 必須在 Redis pool 關閉之前停止(recovery service 可能仍在寫 Redis)
try:
from src.services.ollama_auto_recovery import get_ollama_auto_recovery_service
+
await get_ollama_auto_recovery_service().stop()
logger.info("ollama_failover_system_stopped")
except Exception as e:
@@ -965,6 +1025,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 確保 _verify_and_learn / runbook_generator 寫入不被 SIGTERM cancel
try:
from src.services.auto_repair_service import get_auto_repair_service
+
_repair_svc = get_auto_repair_service()
if hasattr(_repair_svc, "drain_pending_tasks"):
_drain_result = await _repair_svc.drain_pending_tasks(timeout=60.0)
@@ -975,6 +1036,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# AwoooP Phase 4: Platform Worker 優雅停機(2026-05-04 ogt)
try:
from src.workers.platform_worker import stop_platform_worker
+
await stop_platform_worker()
logger.info("platform_worker_stopped")
except Exception as e:
@@ -991,6 +1053,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
await telegram_gw.close()
# Phase 33: Close RAG Service httpx client (ADR-067)
from src.services.knowledge_rag_service import get_knowledge_rag_service
+
await get_knowledge_rag_service().close()
# Phase 5: Close HTTP Clients (統帥鐵律: 連線池回收)
await close_all_http_clients()
@@ -1178,7 +1241,9 @@ async def http_exception_handler(_request: Request, exc: HTTPException) -> JSONR
This is critical for P1-1 fail-closed evidence; without it, all HTTPException
is swallowed by the generic exception handler and downgraded to 500.
"""
- return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
+ return JSONResponse(
+ status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers
+ )
@app.exception_handler(Exception)
@@ -1219,15 +1284,25 @@ app.include_router(csrf_v1.router, prefix="/api/v1", tags=["Security"]) # Phase
app.include_router(dashboard_v1.router, prefix="/api/v1", tags=["Dashboard"])
app.include_router(approvals_v1.router, prefix="/api/v1", tags=["HITL Approvals"])
app.include_router(ai_v1.router, prefix="/api/v1", tags=["AI Decision"])
-app.include_router(ai_governance_v1.router, prefix="/api/v1", tags=["AI Governance"]) # 2026-05-02: /governance 頁面
-app.include_router(ai_slo_v1.router, prefix="/api/v1", tags=["AI SLO"]) # Phase 6 ADR-087
-app.include_router(aiops_kpi_v1.router, prefix="/api/v1", tags=["AIOps KPI"]) # ADR-090 § Phase 7 Dashboard
-app.include_router(aiops_timeline_v1.router, prefix="/api/v1", tags=["AIOps Timeline"]) # 2026-04-27 Wave8-X3 B4
+app.include_router(
+ ai_governance_v1.router, prefix="/api/v1", tags=["AI Governance"]
+) # 2026-05-02: /governance 頁面
+app.include_router(
+ ai_slo_v1.router, prefix="/api/v1", tags=["AI SLO"]
+) # Phase 6 ADR-087
+app.include_router(
+ aiops_kpi_v1.router, prefix="/api/v1", tags=["AIOps KPI"]
+) # ADR-090 § Phase 7 Dashboard
+app.include_router(
+ aiops_timeline_v1.router, prefix="/api/v1", tags=["AIOps Timeline"]
+) # 2026-04-27 Wave8-X3 B4
app.include_router(webhooks_v1.router, prefix="/api/v1", tags=["Webhooks"])
app.include_router(timeline_v1.router, prefix="/api/v1", tags=["Timeline"])
app.include_router(audit_logs_v1.router, prefix="/api/v1", tags=["Audit Logs"])
# 2026-04-09 Claude Sonnet 4.6: alert_operation_log 查詢 API (Sprint 5.2)
-app.include_router(alert_operation_logs_v1.router, prefix="/api/v1", tags=["Alert Operation Logs"])
+app.include_router(
+ alert_operation_logs_v1.router, prefix="/api/v1", tags=["Alert Operation Logs"]
+)
app.include_router(
aider_events_v1.router,
prefix="/api/v1",
@@ -1310,7 +1385,9 @@ app.include_router(
notifications.router, prefix="/api/v1/notifications", tags=["Notifications"]
)
# AwoooP Phase 4 (2026-05-04 ogt): Platform Shell — Shadow Mode Run API
-app.include_router(platform_v1.router, prefix="/api/v1/platform", tags=["AwoooP Platform"])
+app.include_router(
+ platform_v1.router, prefix="/api/v1/platform", tags=["AwoooP Platform"]
+)
# =============================================================================
diff --git a/apps/api/src/models/agent99_completion.py b/apps/api/src/models/agent99_completion.py
index 1ce7e1e08..2e87798cd 100644
--- a/apps/api/src/models/agent99_completion.py
+++ b/apps/api/src/models/agent99_completion.py
@@ -40,7 +40,9 @@ class Agent99CompletionCallbackRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
schema_version: Literal["agent99_completion_callback_v1"]
- callback_id: str = Field(min_length=3, max_length=180, pattern=r"^[A-Za-z0-9_.:-]+$")
+ callback_id: str = Field(
+ min_length=3, max_length=180, pattern=r"^[A-Za-z0-9_.:-]+$"
+ )
project_id: str = Field(default="awoooi", min_length=1, max_length=64)
run_id: str = Field(min_length=3, max_length=180)
trace_id: str = Field(min_length=3, max_length=180)
@@ -71,3 +73,50 @@ class Agent99CompletionCallbackRequest(BaseModel):
telegram: Agent99TelegramReceipt = Field(default_factory=Agent99TelegramReceipt)
problem: Agent99ProblemReceipt = Field(default_factory=Agent99ProblemReceipt)
occurred_at: datetime
+
+
+class Agent99TelegramLifecycleRequest(BaseModel):
+ """Bounded Agent99 event card handed to the canonical Telegram gateway."""
+
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
+
+ schema_version: Literal["agent99_telegram_lifecycle_v1"]
+ delivery_id: str = Field(
+ min_length=12,
+ max_length=180,
+ pattern=r"^[A-Za-z0-9_.:-]+$",
+ )
+ project_id: Literal["awoooi"] = "awoooi"
+ incident_id: str = Field(
+ min_length=3,
+ max_length=180,
+ pattern=r"^[A-Za-z0-9_.:-]+$",
+ )
+ fingerprint: str = Field(min_length=8, max_length=160)
+ event_type: str = Field(
+ min_length=2,
+ max_length=96,
+ pattern=r"^[A-Za-z0-9_.:-]+$",
+ )
+ severity: Literal["info", "warning", "critical"]
+ lifecycle: Literal[
+ "detected",
+ "investigating",
+ "remediating",
+ "verifying",
+ "recovered",
+ "blocked",
+ "unknown",
+ ]
+ state_key: str = Field(min_length=3, max_length=96)
+ run_id: str | None = Field(default=None, max_length=180)
+ title: str = Field(min_length=1, max_length=120)
+ target: str = Field(min_length=1, max_length=120)
+ impact: str = Field(min_length=1, max_length=300)
+ action: str = Field(min_length=1, max_length=360)
+ verification: str = Field(min_length=1, max_length=300)
+ duration_text: str = Field(min_length=1, max_length=80)
+ occurrences: int = Field(default=1, ge=1, le=1_000_000)
+ next_update: str = Field(min_length=1, max_length=300)
+ reply_to_message_id: int | None = Field(default=None, ge=1)
+ occurred_at: datetime
diff --git a/apps/api/src/services/agent99_controlled_dispatch_ledger.py b/apps/api/src/services/agent99_controlled_dispatch_ledger.py
index af334421b..e64ac799a 100644
--- a/apps/api/src/services/agent99_controlled_dispatch_ledger.py
+++ b/apps/api/src/services/agent99_controlled_dispatch_ledger.py
@@ -1752,6 +1752,200 @@ class PostgresAgent99DispatchLedger:
"runtime_closure_verified": False,
}
+ async def record_outcome_timeout_no_write_terminal(
+ self,
+ *,
+ identity: Agent99DispatchIdentity,
+ ) -> dict[str, Any]:
+ """Fail an expired run without replaying transport or claiming closure."""
+
+ now = _utc_now_naive()
+ error_code = "E-AGENT99-OUTCOME-TIMEOUT"
+ try:
+ async with get_db_context(identity.project_id) as db:
+ current_result = await db.execute(
+ select(
+ AwoooPRunState.state,
+ AwoooPRunState.error_detail,
+ AwoooPRunState.timeout_at,
+ )
+ .where(
+ AwoooPRunState.run_id == identity.run_id,
+ AwoooPRunState.project_id == identity.project_id,
+ )
+ .with_for_update()
+ )
+ current = current_result.one_or_none()
+ envelope = _parse_envelope(
+ current.error_detail if current is not None else None
+ )
+ identity_matches = bool(
+ isinstance(envelope, dict)
+ and _stage_identity_matches(
+ identity,
+ envelope.get("identity")
+ if isinstance(envelope.get("identity"), dict)
+ else {},
+ )
+ )
+ if (
+ current is not None
+ and current.state == "failed"
+ and identity_matches
+ and envelope.get("outcome_timeout_no_write_terminal") is True
+ ):
+ return {
+ **envelope,
+ "status": "outcome_timeout_no_write_terminal_idempotent",
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ }
+ if current is None or not identity_matches:
+ return {
+ "status": "outcome_timeout_run_identity_invalid_fail_closed",
+ "receipt_persisted": False,
+ "runtime_closure_verified": False,
+ }
+ timeout_at = current.timeout_at
+ if (
+ current.state != "waiting_tool"
+ or timeout_at is None
+ or timeout_at > now
+ ):
+ return {
+ "status": "outcome_timeout_not_due_no_write",
+ "receipt_persisted": False,
+ "runtime_closure_verified": False,
+ }
+
+ prior_controlled_apply_authorized = bool(
+ envelope.get("controlled_apply_authorized") is True
+ )
+ prior_runtime_execution_authorized = bool(
+ envelope.get("runtime_execution_authorized") is True
+ )
+ prior_runtime_execution_attempted = bool(
+ envelope.get("runtime_execution_attempted") is True
+ )
+ terminal_receipt = {
+ "schema_version": "agent99_outcome_timeout_no_write_v1",
+ "status": "authenticated_outcome_timeout",
+ **_stage_identity(identity),
+ "timeout_at": timeout_at.isoformat(),
+ "terminal_at": now.isoformat(),
+ "terminalizer_runtime_write_performed": False,
+ "original_runtime_write_status": (
+ "unknown_without_authenticated_outcome"
+ ),
+ "transport_replayed": False,
+ "incident_resolution_authorized": False,
+ "learning_writeback_authorized": False,
+ "telegram_authorized": False,
+ "stores_raw_evidence": False,
+ }
+ envelope.update({
+ "status": "outcome_timeout_no_write_terminal",
+ "run_terminal_state": "failed",
+ "outcome_timeout_no_write_terminal": True,
+ "prior_controlled_apply_authorized": (
+ prior_controlled_apply_authorized
+ ),
+ "prior_runtime_execution_authorized": (
+ prior_runtime_execution_authorized
+ ),
+ "runtime_execution_attempted": (
+ prior_runtime_execution_attempted
+ ),
+ "controlled_apply_authorized": False,
+ "runtime_execution_authorized": False,
+ "terminalizer_runtime_write_performed": False,
+ "outcome_runtime_write_status": (
+ "unknown_without_authenticated_outcome"
+ ),
+ "transport_replayed": False,
+ "automation_execution_success": False,
+ "post_verifier_passed": False,
+ "runtime_closure_verified": False,
+ "closure_complete": False,
+ "incident_resolution_authorized": False,
+ "safe_next_action": (
+ "await_new_source_recurrence_after_agent99_relay_recovery"
+ ),
+ "verifier": {
+ **_stage_identity(identity),
+ "step_seq": 2,
+ "status": "failed_authenticated_outcome_timeout",
+ "post_verifier_passed": False,
+ "receipt": terminal_receipt,
+ },
+ "learning_writeback": {
+ **_stage_identity(identity),
+ "step_seq": 3,
+ "status": "not_applicable_outcome_timeout_no_write",
+ "required_receipts": [],
+ "receipt_refs": {},
+ "stores_raw_evidence": False,
+ },
+ })
+ envelope_json = _stable_json(envelope)
+ updated = await db.execute(
+ update(AwoooPRunState)
+ .where(
+ AwoooPRunState.run_id == identity.run_id,
+ AwoooPRunState.project_id == identity.project_id,
+ AwoooPRunState.state == "waiting_tool",
+ AwoooPRunState.timeout_at <= now,
+ )
+ .values(
+ state="failed",
+ output_sha256=_sha256(envelope_json),
+ error_code=error_code,
+ error_detail=envelope_json,
+ heartbeat_at=now,
+ completed_at=now,
+ lease_until=None,
+ next_attempt_at=None,
+ worker_id=None,
+ )
+ .returning(AwoooPRunState.run_id)
+ )
+ persisted = updated.scalar_one_or_none() is not None
+ if persisted:
+ await db.execute(
+ update(AwoooPRunStepJournal)
+ .where(
+ AwoooPRunStepJournal.run_id == identity.run_id,
+ AwoooPRunStepJournal.step_seq.in_([2, 3]),
+ AwoooPRunStepJournal.result_status == "pending",
+ )
+ .values(
+ output_hash=_sha256(_stable_json(terminal_receipt)),
+ compensation_json=terminal_receipt,
+ result_status="failed",
+ error_code=error_code,
+ was_blocked=True,
+ block_reason="authenticated_outcome_timeout_no_write",
+ completed_at=now,
+ )
+ )
+ except Exception as exc:
+ logger.warning(
+ "agent99_outcome_timeout_terminal_failed_fail_closed",
+ incident_id=identity.incident_id,
+ run_id=str(identity.run_id),
+ error=str(exc),
+ )
+ return {
+ "status": "outcome_timeout_terminal_persistence_failed",
+ "receipt_persisted": False,
+ "runtime_closure_verified": False,
+ }
+ return {
+ **envelope,
+ "receipt_persisted": persisted,
+ "runtime_closure_verified": False,
+ }
+
async def record_learning_writeback(
self,
*,
@@ -2236,6 +2430,7 @@ class PostgresAgent99DispatchLedger:
AwoooPRunState.run_id,
AwoooPRunState.state,
AwoooPRunState.error_detail,
+ AwoooPRunState.timeout_at,
)
.where(
AwoooPRunState.project_id == (project_id or "awoooi"),
@@ -2309,6 +2504,7 @@ class PostgresAgent99DispatchLedger:
"identity": identity,
"run_state": str(row.state),
"receipt": envelope,
+ "timeout_at": getattr(row, "timeout_at", None),
})
return items
diff --git a/apps/api/src/services/agent99_telegram_lifecycle.py b/apps/api/src/services/agent99_telegram_lifecycle.py
new file mode 100644
index 000000000..799e93ef1
--- /dev/null
+++ b/apps/api/src/services/agent99_telegram_lifecycle.py
@@ -0,0 +1,114 @@
+"""Canonical, durable Telegram lifecycle delivery for Windows Agent99."""
+
+from __future__ import annotations
+
+import html
+from typing import Any
+
+from src.models.agent99_completion import Agent99TelegramLifecycleRequest
+from src.services.telegram_gateway import get_telegram_gateway
+
+
+_LIFECYCLE_LABELS = {
+ "detected": "已偵測 / DETECTED",
+ "investigating": "調查中 / INVESTIGATING",
+ "remediating": "修復中 / REMEDIATING",
+ "verifying": "驗證中 / VERIFYING",
+ "recovered": "已恢復 / RECOVERED",
+ "blocked": "需介入 / ACTION REQUIRED",
+ "unknown": "狀態未知 / UNKNOWN",
+}
+
+_SEVERITY_LABELS = {
+ "critical": "重大 / CRITICAL",
+ "warning": "警告 / WARNING",
+ "info": "資訊 / INFO",
+}
+
+
+def _format_lifecycle_card(payload: Agent99TelegramLifecycleRequest) -> str:
+ def esc(value: object) -> str:
+ return html.escape(str(value or ""))
+
+ return "\n".join(
+ [
+ f"Agent99 事件|{esc(payload.title)}",
+ (
+ f"狀態:{esc(_LIFECYCLE_LABELS[payload.lifecycle])}"
+ f" 等級:{esc(_SEVERITY_LABELS[payload.severity])}"
+ ),
+ "────────────────────",
+ f"資產 {esc(payload.target)}",
+ f"影響 {esc(payload.impact)}",
+ f"AI 處置 {esc(payload.action)}",
+ f"驗證 {esc(payload.verification)}",
+ "────────────────────",
+ (
+ f"事件:{esc(payload.incident_id)} "
+ f"發生:{payload.occurrences} 次 耗時:{esc(payload.duration_text)}"
+ ),
+ f"下一次更新:{esc(payload.next_update)}",
+ f"時間:{esc(payload.occurred_at.isoformat())}",
+ ]
+ )[:4096]
+
+
+async def deliver_agent99_telegram_lifecycle(
+ payload: Agent99TelegramLifecycleRequest,
+) -> dict[str, Any]:
+ """Send once through the registered AWOOOI SRE route and verify its ack."""
+
+ response = await get_telegram_gateway().send_agent99_lifecycle_receipt(
+ delivery_id=payload.delivery_id,
+ incident_id=payload.incident_id,
+ state_key=payload.state_key,
+ text=_format_lifecycle_card(payload),
+ priority="P0" if payload.severity == "critical" else "P1",
+ project_id=payload.project_id,
+ reply_to_message_id=payload.reply_to_message_id,
+ )
+ result = response.get("result") if isinstance(response, dict) else None
+ message_id = str(result.get("message_id") or "") if isinstance(result, dict) else ""
+ delivery_context = (
+ response.get("_awooop_delivery_context")
+ if isinstance(response, dict)
+ and isinstance(response.get("_awooop_delivery_context"), dict)
+ else {}
+ )
+ delivery_status = (
+ str(response.get("_awooop_delivery_status") or "")
+ if isinstance(response, dict)
+ else ""
+ )
+ durable_ack = bool(
+ isinstance(response, dict)
+ and response.get("_awooop_outbound_mirror_acknowledged") is True
+ )
+ destination_verified = bool(
+ delivery_status == "sent_reused"
+ or delivery_context.get("destination_binding_verified") is True
+ )
+ ok = bool(
+ durable_ack
+ and destination_verified
+ and message_id
+ and delivery_status in {"sent", "sent_reused"}
+ )
+ return {
+ "schema_version": "agent99_telegram_lifecycle_receipt_v1",
+ "ok": ok,
+ "delivery_id": payload.delivery_id,
+ "incident_id": payload.incident_id,
+ "event_type": payload.event_type,
+ "lifecycle": payload.lifecycle,
+ "provider_message_id": message_id or None,
+ "delivery_status": delivery_status or "unknown",
+ "durable_outbound_acknowledged": durable_ack,
+ "destination_binding_verified": destination_verified,
+ "provider_send_performed": bool(
+ isinstance(response, dict)
+ and response.get("_awooop_provider_send_performed") is True
+ ),
+ "stores_secret": False,
+ "raw_response_stored": False,
+ }
diff --git a/apps/api/src/services/ai_automation_asset_capability_matrix.py b/apps/api/src/services/ai_automation_asset_capability_matrix.py
index 27549f312..2f35dba01 100644
--- a/apps/api/src/services/ai_automation_asset_capability_matrix.py
+++ b/apps/api/src/services/ai_automation_asset_capability_matrix.py
@@ -631,6 +631,7 @@ def _receipt_controls(
fresh = bool(
row
and str(row.get("status") or "") == "success"
+ and output.get("closed") is True
and _is_fresh(
created_at,
now=now,
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index 292409548..4a1ae6d0c 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -2343,13 +2343,14 @@ def _legacy_outbound_run_id(chat_id: str, provider_message_id: str) -> UUID:
def _controlled_apply_result_delivery_identity(
source_envelope_extra: object,
) -> dict[str, str] | None:
- """Return the stable identity required before a controlled-result send."""
+ """Return the stable identity required before a durable lifecycle send."""
if not isinstance(source_envelope_extra, dict):
return None
callback_reply = source_envelope_extra.get("callback_reply")
if not isinstance(callback_reply, dict):
return None
- if callback_reply.get("action") != "controlled_apply_result":
+ delivery_kind = str(callback_reply.get("action") or "").strip()
+ if delivery_kind not in {"controlled_apply_result", "agent99_lifecycle"}:
return None
identity = {
@@ -2359,15 +2360,22 @@ def _controlled_apply_result_delivery_identity(
).strip(),
"incident_id": str(callback_reply.get("incident_id") or "").strip(),
"apply_op_id": str(callback_reply.get("apply_op_id") or "").strip(),
+ "delivery_kind": delivery_kind,
}
return identity if all(identity.values()) else None
def _controlled_apply_result_delivery_run_id(identity: dict[str, str]) -> UUID:
"""Build one stable outbox identity for one controlled apply result."""
+ delivery_kind = identity.get("delivery_kind", "controlled_apply_result")
+ namespace = (
+ "awoooi:controlled-apply-result"
+ if delivery_kind == "controlled_apply_result"
+ else f"awoooi:{delivery_kind}"
+ )
return uuid5(
NAMESPACE_URL,
- "awoooi:controlled-apply-result:"
+ f"{namespace}:"
f"{identity['project_id']}:"
f"{identity['automation_run_id']}:"
f"{identity['incident_id']}:"
@@ -5774,6 +5782,10 @@ class TelegramGateway:
project_id = identity["project_id"]
delivery_run_id = _controlled_apply_result_delivery_run_id(identity)
+ delivery_kind = identity.get(
+ "delivery_kind",
+ "controlled_apply_result",
+ )
notification_policy = source_envelope_extra.get("notification_policy")
if not isinstance(notification_policy, dict):
notification_policy = {}
@@ -5928,9 +5940,7 @@ class TelegramGateway:
callback_reply = dict(
reservation_extra.get("callback_reply") or {}
)
- callback_reply["status"] = (
- "controlled_apply_result_reserved"
- )
+ callback_reply["status"] = f"{delivery_kind}_reserved"
reservation_extra["callback_reply"] = callback_reply
reservation_message_id = str(
await record_outbound_message(
@@ -5956,7 +5966,7 @@ class TelegramGateway:
),
provider_message_id=None,
send_status="pending",
- triggered_by_state="controlled_apply_result",
+ triggered_by_state=delivery_kind,
is_shadow=False,
)
)
@@ -6066,7 +6076,11 @@ class TelegramGateway:
if not message_id or not run_id or not provider_message_id:
return False
- lock_key = f"telegram-controlled-apply-result:{run_id}"
+ delivery_kind = identity.get(
+ "delivery_kind",
+ "controlled_apply_result",
+ )
+ lock_key = f"telegram-{delivery_kind}:{run_id}"
for attempt in range(_CONTROLLED_APPLY_OUTBOUND_FINALIZE_ATTEMPTS):
finalized = False
try:
@@ -6086,7 +6100,7 @@ class TelegramGateway:
send_status = 'sent',
send_error = NULL,
sent_at = coalesce(sent_at, NOW()),
- triggered_by_state = 'controlled_apply_result',
+ triggered_by_state = :triggered_by_state,
source_envelope = jsonb_set(
jsonb_set(
source_envelope,
@@ -6114,6 +6128,7 @@ class TelegramGateway:
"message_id": message_id,
"run_id": run_id,
"provider_message_id": provider_message_id,
+ "triggered_by_state": delivery_kind,
},
)
row = result.mappings().one_or_none()
@@ -6276,7 +6291,8 @@ class TelegramGateway:
)
controlled_apply_result_requested = bool(
isinstance(callback_reply, dict)
- and callback_reply.get("action") == "controlled_apply_result"
+ and callback_reply.get("action")
+ in {"controlled_apply_result", "agent99_lifecycle"}
)
controlled_delivery_identity = (
_controlled_apply_result_delivery_identity(source_envelope_extra)
@@ -11586,6 +11602,80 @@ class TelegramGateway:
payload["reply_to_message_id"] = inbound_message_id
return await self._send_request("sendMessage", payload)
+ async def send_agent99_lifecycle_receipt(
+ self,
+ *,
+ delivery_id: str,
+ incident_id: str,
+ state_key: str,
+ text: str,
+ priority: str,
+ project_id: str = "awoooi",
+ reply_to_message_id: int | None = None,
+ ) -> dict:
+ """Send one structured Agent99 lifecycle update with a durable ack."""
+
+ source_extra = _callback_reply_source_envelope_extra(
+ incident_id=incident_id,
+ failure_context="agent99_lifecycle",
+ status="agent99_lifecycle_pending",
+ chunk_index=0,
+ chunk_count=1,
+ callback_action="agent99_lifecycle",
+ parse_mode="HTML",
+ parent_message_id=reply_to_message_id,
+ )
+ if source_extra is None:
+ return {
+ "ok": False,
+ "_awooop_outbound_mirror_acknowledged": False,
+ "_awooop_delivery_status": "incident_identity_missing",
+ "_awooop_provider_send_performed": False,
+ }
+ source_extra["outbound_message_type"] = (
+ "final" if "recovered" in state_key else "error"
+ )
+ source_extra["execution_kind"] = "agent99_lifecycle"
+ source_extra["notification_policy"] = {
+ "policy_version": "agent99_lifecycle_send_once_v1",
+ "provider_delivery": "immediate",
+ "disposition": "send_once",
+ "provider_send_performed": None,
+ "details_retained_in": [
+ "awooop_outbound_message",
+ "telegram_outbound_receipts",
+ ],
+ }
+ callback_reply = source_extra.get("callback_reply")
+ if isinstance(callback_reply, dict):
+ callback_reply["automation_run_id"] = delivery_id
+ callback_reply["apply_op_id"] = state_key
+ callback_reply["project_id"] = project_id or "awoooi"
+ source_refs = source_extra.get("source_refs")
+ if isinstance(source_refs, dict):
+ source_refs["automation_run_ids"] = [delivery_id]
+
+ payload: dict[str, object] = {
+ "text": text[:4096],
+ "parse_mode": "HTML",
+ "disable_web_page_preview": True,
+ _CANONICAL_ROUTE_CONTEXT_KEY: {
+ "product_id": "awoooi",
+ "signal_family": "incident_lifecycle",
+ "severity": priority,
+ },
+ _AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY: source_extra,
+ }
+ reply_markup = incident_truth_chain_reply_markup(
+ incident_id,
+ project_id=project_id or "awoooi",
+ )
+ if reply_markup:
+ payload["reply_markup"] = reply_markup
+ if reply_to_message_id is not None:
+ payload["reply_to_message_id"] = reply_to_message_id
+ return await self._send_request("sendMessage", payload)
+
async def send_controlled_apply_result_receipt(
self,
*,
diff --git a/apps/api/src/workers/signal_worker.py b/apps/api/src/workers/signal_worker.py
index ef9124ded..55b84a433 100644
--- a/apps/api/src/workers/signal_worker.py
+++ b/apps/api/src/workers/signal_worker.py
@@ -71,6 +71,7 @@ GRACEFUL_SHUTDOWN_TIMEOUT_S = 75 # K8s terminationGracePeriodSeconds: 90
# Signal Worker
# =============================================================================
+
class SignalWorker:
"""
Redis Streams 訊號消費者
@@ -366,7 +367,9 @@ class SignalWorker:
span.set_attribute("messaging.destination", STREAM_KEY)
span.set_attribute("messaging.message_id", message_id)
span.set_attribute("signal.source", data.get("source", "unknown"))
- span.set_attribute("signal.alert_name", data.get("alert_name", "unknown"))
+ span.set_attribute(
+ "signal.alert_name", data.get("alert_name", "unknown")
+ )
span.set_attribute("signal.severity", data.get("severity", "unknown"))
logger.info(
@@ -400,7 +403,9 @@ class SignalWorker:
severity=incident.severity.value,
signal_count=len(incident.signals),
affected_services=incident.affected_services,
- persisted_to_pg=getattr(incident, "persisted_to_pg", False), # 2026-04-01 ogt: BrainIncident 無此欄位 (ADR-046 P2-01)
+ persisted_to_pg=getattr(
+ incident, "persisted_to_pg", False
+ ), # 2026-04-01 ogt: BrainIncident 無此欄位 (ADR-046 P2-01)
)
try:
from src.services.signal_observation_service import (
@@ -505,6 +510,7 @@ def get_signal_worker() -> SignalWorker:
# Standalone Entry Point (for K8s Worker Deployment)
# =============================================================================
+
async def _write_health_files() -> None:
"""
Write health check files for K8s probes.
@@ -542,10 +548,7 @@ async def _heartbeat_loop(shutdown_event: asyncio.Event) -> None:
# 等待下次心跳或收到關閉信號
try:
- await asyncio.wait_for(
- shutdown_event.wait(),
- timeout=HEARTBEAT_INTERVAL
- )
+ await asyncio.wait_for(shutdown_event.wait(), timeout=HEARTBEAT_INTERVAL)
break # 收到關閉信號
except TimeoutError:
continue # 超時,繼續下次心跳
@@ -575,7 +578,9 @@ async def _main() -> None:
"signal_worker_standalone_starting",
environment=settings.ENVIRONMENT,
redis_url=settings.REDIS_URL.split("@")[-1] if settings.REDIS_URL else "N/A",
- database_url=settings.DATABASE_URL.split("@")[-1] if settings.DATABASE_URL else "N/A",
+ database_url=settings.DATABASE_URL.split("@")[-1]
+ if settings.DATABASE_URL
+ else "N/A",
)
# Initialize Redis (API pool + Worker 專屬長連線池)
@@ -695,6 +700,9 @@ async def _main() -> None:
telegram_dispatch_performed=False,
)
if settings.ENABLE_SECURITY_CONTROL_PLANE_MAINTENANCE_WORKER:
+ from src.jobs.asset_capability_reconciliation_job import (
+ run_asset_capability_reconciliation_loop,
+ )
from src.jobs.asset_change_tracker_job import run_asset_change_tracker_loop
from src.jobs.asset_scanner_job import run_asset_scanner_loop
from src.jobs.compliance_scanner_job import run_compliance_scanner_loop
@@ -707,6 +715,10 @@ async def _main() -> None:
("run_compliance_scanner_loop", run_compliance_scanner_loop),
("run_coverage_evaluator_loop", run_coverage_evaluator_loop),
("run_asset_change_tracker_loop", run_asset_change_tracker_loop),
+ (
+ "run_asset_capability_reconciliation_loop",
+ run_asset_capability_reconciliation_loop,
+ ),
)
security_maintenance_tasks = [
asyncio.create_task(loop(), name=task_name)
@@ -791,12 +803,8 @@ async def _main() -> None:
"signal_worker_agent99_controlled_dispatch_reconciler_skipped_fail_closed",
owner="signal_worker",
reason="authenticated_relay_not_configured",
- relay_url_configured=bool(
- settings.AGENT99_SRE_ALERT_RELAY_URL.strip()
- ),
- relay_token_configured=bool(
- settings.AGENT99_SRE_ALERT_RELAY_TOKEN.strip()
- ),
+ relay_url_configured=bool(settings.AGENT99_SRE_ALERT_RELAY_URL.strip()),
+ relay_token_configured=bool(settings.AGENT99_SRE_ALERT_RELAY_TOKEN.strip()),
)
# Setup graceful shutdown
shutdown_event = asyncio.Event()
@@ -873,6 +881,7 @@ async def _main() -> None:
# Remove health files
from pathlib import Path
+
Path("/tmp/worker-healthy").unlink(missing_ok=True)
Path("/tmp/worker-ready").unlink(missing_ok=True)
diff --git a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py
index b230d7722..fc5b707a5 100644
--- a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py
+++ b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py
@@ -101,6 +101,7 @@ async def test_reconciler_consumes_authenticated_outcome_and_closes_same_run(
"status_reconcile_requested": 0,
"status_reconcile_pending": 0,
"external_no_write_reconciled": 0,
+ "outcome_timeout_terminalized": 0,
"verifier_written": 1,
"closed": 1,
}
@@ -130,6 +131,7 @@ async def test_reconciler_does_not_finalize_without_outcome(monkeypatch) -> None
"status_reconcile_requested": 0,
"status_reconcile_pending": 0,
"external_no_write_reconciled": 0,
+ "outcome_timeout_terminalized": 0,
"verifier_written": 0,
"closed": 0,
}
diff --git a/apps/api/tests/test_agent99_same_run_reconcile.py b/apps/api/tests/test_agent99_same_run_reconcile.py
index 44d246dbb..d987aba8f 100644
--- a/apps/api/tests/test_agent99_same_run_reconcile.py
+++ b/apps/api/tests/test_agent99_same_run_reconcile.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
from copy import deepcopy
+from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -812,6 +813,184 @@ async def test_external_no_write_terminal_rejects_malformed_source_counts(
assert result["receipt_persisted"] is False
+def test_dispatch_timeout_elapsed_handles_naive_and_aware_datetimes() -> None:
+ past = datetime.now(UTC) - timedelta(minutes=1)
+ future = datetime.now(UTC) + timedelta(minutes=1)
+
+ assert job._dispatch_timeout_elapsed(past) is True
+ assert job._dispatch_timeout_elapsed(past.replace(tzinfo=None)) is True
+ assert job._dispatch_timeout_elapsed(future) is False
+ assert job._dispatch_timeout_elapsed(None) is False
+
+
+@pytest.mark.asyncio
+async def test_reconciler_terminalizes_expired_run_without_outcome(
+ monkeypatch,
+) -> None:
+ identity = build_agent99_dispatch_identity(
+ project_id="awoooi",
+ incident_id="INC-BRR-EXPIRED",
+ source_fingerprint="backup-restore:" + "b" * 64,
+ route_id="agent99:backup_health:Status",
+ work_item_id="agent99-dispatch:awoooi:INC-BRR-EXPIRED",
+ )
+
+ class TimeoutLedger(_Ledger):
+ def __init__(self) -> None:
+ super().__init__()
+ self.timeout_calls: list[object] = []
+
+ async def list_reconcilable(self, **_kwargs): # type: ignore[no-untyped-def]
+ return [{
+ "identity": identity,
+ "receipt": self.list_receipt,
+ "timeout_at": datetime.now(UTC) - timedelta(minutes=1),
+ }]
+
+ async def record_outcome_timeout_no_write_terminal(
+ self,
+ **kwargs,
+ ): # type: ignore[no-untyped-def]
+ self.timeout_calls.append(kwargs["identity"])
+ return {
+ "status": "outcome_timeout_no_write_terminal",
+ "receipt_persisted": True,
+ "runtime_closure_verified": False,
+ }
+
+ ledger = TimeoutLedger()
+ monkeypatch.setattr(job, "get_agent99_dispatch_ledger", lambda: ledger)
+ monkeypatch.setattr(job, "read_agent99_sre_outcome", lambda _run: None)
+
+ result = await job.reconcile_agent99_controlled_dispatches_once()
+
+ assert result["outcome_timeout_terminalized"] == 1
+ assert result["verifier_written"] == 0
+ assert ledger.timeout_calls == [identity]
+
+
+@pytest.mark.asyncio
+async def test_outcome_timeout_terminal_does_not_resolve_incident_or_replay(
+ monkeypatch,
+) -> None:
+ envelope = build_agent99_dispatch_receipt_envelope(
+ identity=IDENTITY,
+ dispatch_receipt={
+ "accepted": True,
+ "inbox_triggered": True,
+ "queue_accepted": True,
+ "dispatch_identity_matched": True,
+ "delivery_certainty": "delivered",
+ },
+ controlled_apply_authorized=True,
+ )
+
+ class TimeoutDB:
+ def __init__(self) -> None:
+ self.call = 0
+ self.statements: list[str] = []
+
+ async def execute(self, statement): # type: ignore[no-untyped-def]
+ self.call += 1
+ self.statements.append(str(statement))
+ if self.call == 1:
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="waiting_tool",
+ error_detail=json.dumps(envelope),
+ timeout_at=(
+ datetime.now(UTC).replace(tzinfo=None)
+ - timedelta(minutes=1)
+ ),
+ )
+ )
+ if self.call == 2:
+ return _ScalarResult(value=IDENTITY.run_id)
+ return _ScalarResult()
+
+ db = TimeoutDB()
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(db),
+ )
+
+ result = (
+ await PostgresAgent99DispatchLedger()
+ .record_outcome_timeout_no_write_terminal(identity=IDENTITY)
+ )
+
+ assert result["receipt_persisted"] is True
+ assert result["run_terminal_state"] == "failed"
+ assert result["prior_controlled_apply_authorized"] is True
+ assert result["controlled_apply_authorized"] is False
+ assert result["terminalizer_runtime_write_performed"] is False
+ assert result["outcome_runtime_write_status"] == (
+ "unknown_without_authenticated_outcome"
+ )
+ assert result["transport_replayed"] is False
+ assert result["automation_execution_success"] is False
+ assert result["post_verifier_passed"] is False
+ assert result["runtime_closure_verified"] is False
+ assert result["incident_resolution_authorized"] is False
+ assert result["learning_writeback"]["status"] == (
+ "not_applicable_outcome_timeout_no_write"
+ )
+ sql = "\n".join(db.statements).lower()
+ assert "incident_records" not in sql
+ assert "alert_operation_logs" not in sql
+ assert "telegram" not in sql
+
+
+@pytest.mark.asyncio
+async def test_outcome_timeout_terminal_rejects_unexpired_run(
+ monkeypatch,
+) -> None:
+ envelope = build_agent99_dispatch_receipt_envelope(
+ identity=IDENTITY,
+ dispatch_receipt={
+ "accepted": True,
+ "inbox_triggered": True,
+ "queue_accepted": True,
+ "dispatch_identity_matched": True,
+ },
+ controlled_apply_authorized=False,
+ )
+
+ class FutureTimeoutDB:
+ def __init__(self) -> None:
+ self.call = 0
+
+ async def execute(self, _statement): # type: ignore[no-untyped-def]
+ self.call += 1
+ return _ScalarResult(
+ row=SimpleNamespace(
+ state="waiting_tool",
+ error_detail=json.dumps(envelope),
+ timeout_at=(
+ datetime.now(UTC).replace(tzinfo=None)
+ + timedelta(minutes=1)
+ ),
+ )
+ )
+
+ db = FutureTimeoutDB()
+ monkeypatch.setattr(
+ ledger_module,
+ "get_db_context",
+ lambda _project_id: _Context(db),
+ )
+
+ result = (
+ await PostgresAgent99DispatchLedger()
+ .record_outcome_timeout_no_write_terminal(identity=IDENTITY)
+ )
+
+ assert result["status"] == "outcome_timeout_not_due_no_write"
+ assert result["receipt_persisted"] is False
+ assert db.call == 1
+
+
def test_windows_relay_and_control_plane_enforce_same_run_no_write_contract() -> None:
root = Path(__file__).resolve().parents[3]
relay = (root / "agent99-sre-alert-relay.ps1").read_text(encoding="utf-8")
diff --git a/apps/api/tests/test_agent99_telegram_lifecycle_api.py b/apps/api/tests/test_agent99_telegram_lifecycle_api.py
new file mode 100644
index 000000000..1a0996409
--- /dev/null
+++ b/apps/api/tests/test_agent99_telegram_lifecycle_api.py
@@ -0,0 +1,208 @@
+from __future__ import annotations
+
+# ruff: noqa: E402
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from pydantic import ValidationError
+
+from src.api.v1 import agents as agents_api
+from src.core.config import settings
+from src.models.agent99_completion import Agent99TelegramLifecycleRequest
+from src.services import agent99_telegram_lifecycle as lifecycle_service
+from src.services import telegram_gateway as gateway_service
+
+
+def payload() -> dict:
+ return {
+ "schema_version": "agent99_telegram_lifecycle_v1",
+ "delivery_id": "agent99-lifecycle-0123456789abcdef0123456789abcdef01234567",
+ "project_id": "awoooi",
+ "incident_id": "INC-20260715-AG9901",
+ "fingerprint": "0123456789abcdef0123456789abcdef",
+ "event_type": "performance_warning",
+ "severity": "warning",
+ "lifecycle": "investigating",
+ "state_key": "investigating|warning",
+ "run_id": "run-agent99-20260715-01",
+ "title": "Host 110 主機效能異常",
+ "target": "192.168.0.110",
+ "impact": "資源壓力可能降低服務回應速度。",
+ "action": "Agent99 正在執行 allowlisted 診斷與降載。",
+ "verification": "等待 CPU、記憶體與磁碟 post-verifier。",
+ "duration_text": "12.4 秒",
+ "occurrences": 2,
+ "next_update": "狀態改變時更新,相同狀態不重複推播。",
+ "occurred_at": "2026-07-15T04:20:00+08:00",
+ }
+
+
+def app_client() -> TestClient:
+ app = FastAPI()
+ app.include_router(agents_api.router, prefix="/api/v1")
+ return TestClient(app)
+
+
+def test_lifecycle_payload_rejects_unbounded_content() -> None:
+ with pytest.raises(ValidationError):
+ Agent99TelegramLifecycleRequest.model_validate(
+ {**payload(), "raw_log": "must-not-enter-lifecycle-ingress"}
+ )
+ with pytest.raises(ValidationError):
+ Agent99TelegramLifecycleRequest.model_validate(
+ {**payload(), "incident_id": r"C:\Wooo\Agent99\evidence\raw.json"}
+ )
+ with pytest.raises(ValidationError):
+ Agent99TelegramLifecycleRequest.model_validate(
+ {**payload(), "project_id": "unregistered-product"}
+ )
+
+
+def test_lifecycle_endpoint_rejects_wrong_token(monkeypatch) -> None:
+ monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected")
+
+ response = app_client().post(
+ "/api/v1/agents/agent99/telegram-lifecycle",
+ headers={"X-Agent99-Lifecycle-Token": "wrong"},
+ json=payload(),
+ )
+
+ assert response.status_code == 401
+ assert response.json()["detail"] == "agent99_lifecycle_auth_failed"
+
+
+def test_lifecycle_endpoint_requires_durable_gateway_ack(monkeypatch) -> None:
+ monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected")
+
+ async def failed_delivery(request):
+ return {
+ "ok": False,
+ "delivery_id": request.delivery_id,
+ "delivery_status": "pending_unknown",
+ }
+
+ monkeypatch.setattr(
+ agents_api,
+ "deliver_agent99_telegram_lifecycle",
+ failed_delivery,
+ )
+ response = app_client().post(
+ "/api/v1/agents/agent99/telegram-lifecycle",
+ headers={"X-Agent99-Lifecycle-Token": "expected"},
+ json=payload(),
+ )
+
+ assert response.status_code == 503
+ assert "pending_unknown" in response.json()["detail"]
+
+
+def test_lifecycle_endpoint_returns_same_delivery_receipt(monkeypatch) -> None:
+ monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected")
+
+ async def successful_delivery(request):
+ return {
+ "schema_version": "agent99_telegram_lifecycle_receipt_v1",
+ "ok": True,
+ "delivery_id": request.delivery_id,
+ "incident_id": request.incident_id,
+ "delivery_status": "sent",
+ "provider_message_id": "8123",
+ "durable_outbound_acknowledged": True,
+ }
+
+ monkeypatch.setattr(
+ agents_api,
+ "deliver_agent99_telegram_lifecycle",
+ successful_delivery,
+ )
+ response = app_client().post(
+ "/api/v1/agents/agent99/telegram-lifecycle",
+ headers={"X-Agent99-Lifecycle-Token": "expected"},
+ json=payload(),
+ )
+
+ assert response.status_code == 200
+ assert response.json()["delivery_id"] == payload()["delivery_id"]
+ assert response.json()["provider_message_id"] == "8123"
+
+
+def test_lifecycle_outbox_identity_is_stable_and_separate() -> None:
+ base = {
+ "callback_reply": {
+ "project_id": "awoooi",
+ "automation_run_id": "agent99-lifecycle-0123456789",
+ "incident_id": "INC-20260715-AG9901",
+ "apply_op_id": "investigating|warning",
+ }
+ }
+ lifecycle_source = {
+ "callback_reply": {
+ **base["callback_reply"],
+ "action": "agent99_lifecycle",
+ }
+ }
+ controlled_source = {
+ "callback_reply": {
+ **base["callback_reply"],
+ "action": "controlled_apply_result",
+ }
+ }
+
+ lifecycle_identity = gateway_service._controlled_apply_result_delivery_identity(
+ lifecycle_source
+ )
+ controlled_identity = gateway_service._controlled_apply_result_delivery_identity(
+ controlled_source
+ )
+
+ assert lifecycle_identity is not None
+ assert controlled_identity is not None
+ assert lifecycle_identity["delivery_kind"] == "agent99_lifecycle"
+ assert controlled_identity["delivery_kind"] == "controlled_apply_result"
+ assert gateway_service._controlled_apply_result_delivery_run_id(
+ lifecycle_identity
+ ) == gateway_service._controlled_apply_result_delivery_run_id(lifecycle_identity)
+ assert gateway_service._controlled_apply_result_delivery_run_id(
+ lifecycle_identity
+ ) != gateway_service._controlled_apply_result_delivery_run_id(controlled_identity)
+
+
+@pytest.mark.asyncio
+async def test_lifecycle_service_uses_durable_canonical_sender(monkeypatch) -> None:
+ calls: list[dict] = []
+
+ class Gateway:
+ async def send_agent99_lifecycle_receipt(self, **kwargs):
+ calls.append(kwargs)
+ return {
+ "ok": True,
+ "result": {"message_id": 9123},
+ "_awooop_outbound_mirror_acknowledged": True,
+ "_awooop_delivery_status": "sent",
+ "_awooop_provider_send_performed": True,
+ "_awooop_delivery_context": {
+ "destination_binding_verified": True,
+ },
+ }
+
+ monkeypatch.setattr(
+ lifecycle_service,
+ "get_telegram_gateway",
+ lambda: Gateway(),
+ )
+ request = Agent99TelegramLifecycleRequest.model_validate(payload())
+
+ receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request)
+
+ assert receipt["ok"] is True
+ assert receipt["durable_outbound_acknowledged"] is True
+ assert receipt["destination_binding_verified"] is True
+ assert receipt["provider_message_id"] == "9123"
+ assert calls[0]["priority"] == "P1"
+ assert "Agent99 事件" in calls[0]["text"]
+ assert "AI 處置" in calls[0]["text"]
+ assert "raw_log" not in calls[0]["text"]
diff --git a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py
index 3ed3908d6..db4cf9002 100644
--- a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py
+++ b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py
@@ -111,9 +111,11 @@ def test_agent99_telegram_lifecycle_fails_closed_without_direct_sender() -> None
assert "lastRunKey = $Card.runKey" in source
assert "$previousRunKey -eq [string]$incidentCard.runKey" in source
assert '$dedupeParts += "run=$($incidentCard.runKey)"' in source
- assert 'error = "canonical_telegram_gateway_transport_required"' in source
- assert 'providerSendPerformed = $false' in source
- assert 'routeStatus = "blocked_no_egress"' in source
+ assert "agent99/telegram-lifecycle" in source
+ assert '"X-Agent99-Lifecycle-Token" = $Token' in source
+ assert "durable_outbound_acknowledged" in source
+ assert "destination_binding_verified" in source
+ assert 'reason = "canonical_reconciler_owns_terminal_receipt"' in source
assert "function Send-AgentTelegramRelay" not in source
assert "function Send-AgentTelegramPhotoDirect" not in source
assert "telegram_receipt_b64=" not in source
@@ -403,10 +405,13 @@ def test_agent99_completion_callback_waits_for_durable_same_trace_readback() ->
config = json.loads((ROOT / "agent99.config.99.example.json").read_text())
callback = config["completionCallback"]
+ lifecycle = config["telegram"]["canonicalGateway"]
assert callback["enabled"] is True
assert callback["url"].startswith("https://awoooi.wooo.work/")
assert callback["tokenEnv"] == "AGENT99_SRE_RELAY_TOKEN"
assert callback["retryAfterMinutes"] == 5
+ assert lifecycle["url"].endswith("/api/v1/agents/agent99/telegram-lifecycle")
+ assert lifecycle["tokenEnv"] == "AGENT99_SRE_RELAY_TOKEN"
assert 'schema_version = "agent99_completion_callback_v1"' in source
assert '"X-Agent99-Completion-Token" = $token' in source
assert '$receipt.durableReadback' in source
@@ -415,10 +420,21 @@ def test_agent99_completion_callback_waits_for_durable_same_trace_readback() ->
assert 'state\\completion-callbacks\\pending' in source
assert 'Invoke-AgentPendingCompletionCallbacks' in source
assert 'Add-DefaultProperty $config "completionCallback"' in deploy
+ assert 'Add-DefaultProperty $config.telegram "canonicalGateway"' in deploy
assert 'rawResponseStored = $false' in source
assert 'secretValueLogged = $false' in source
+def test_agent99_selfhealth_ignores_suppressed_lifecycle_attempts() -> None:
+ source = CONTROL.read_text(encoding="utf-8")
+ function = source[source.index("function Test-AgentRecentTelegramDelivery") :]
+ function = function[: function.index("function Test-AgentRuntimeManifest")]
+
+ assert 'PSObject.Properties["suppressed"]' in function
+ assert "$_.suppressed -eq $true" in function
+ assert "$_.relay.durableAck -eq $true" in function
+
+
def test_agent99_object_reader_preserves_ordered_callback_identity() -> None:
source = CONTROL.read_text(encoding="utf-8")
helper = source[source.index("function Get-AgentObjectValue") :]
diff --git a/apps/api/tests/test_ai_automation_asset_capability_matrix.py b/apps/api/tests/test_ai_automation_asset_capability_matrix.py
index e36e7d95f..ad2680394 100644
--- a/apps/api/tests/test_ai_automation_asset_capability_matrix.py
+++ b/apps/api/tests/test_ai_automation_asset_capability_matrix.py
@@ -1,12 +1,15 @@
from __future__ import annotations
import asyncio
+import inspect
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
+import src.jobs.asset_capability_reconciliation_job as reconciliation_job
import src.services.ai_automation_asset_capability_matrix as matrix_service
from src.jobs.asset_capability_reconciliation_job import (
+ _persist_reconciliation,
build_reconciliation_summary_payload,
build_reconciliation_work_item_input,
)
@@ -212,6 +215,7 @@ def test_live_asset_has_per_stage_covered_status_and_closes_after_receipt() -> N
"candidate_count": 0,
"repository_readback_count": 0,
"repository_readback_verified": True,
+ "closed": True,
},
}
@@ -232,6 +236,38 @@ def test_live_asset_has_per_stage_covered_status_and_closes_after_receipt() -> N
assert matrix["summary"]["gap_asset_count"] == 0
+def test_reconciliation_receipt_requires_closed_terminal() -> None:
+ now = datetime(2026, 7, 11, 12, 0, tzinfo=UTC)
+ initial = build_asset_capability_matrix(
+ _single_host_scope(),
+ live_assets=[_fully_covered_host(now)],
+ latest_run={"run_id": "run-1", "status": "success", "ended_at": now},
+ repo_root=_REPO_ROOT,
+ generated_at=now,
+ )
+ matrix = build_asset_capability_matrix(
+ _single_host_scope(),
+ live_assets=[_fully_covered_host(now)],
+ latest_run={"run_id": "run-1", "status": "success", "ended_at": now},
+ reconciliation_receipt={
+ "status": "success",
+ "created_at": now,
+ "output": {
+ "matrix_fingerprint": initial["matrix_fingerprint"],
+ "candidate_count": 0,
+ "repository_readback_count": 0,
+ "repository_readback_verified": True,
+ "closed": False,
+ },
+ },
+ repo_root=_REPO_ROOT,
+ generated_at=now,
+ )
+
+ assert matrix["controls"]["daily_reconciliation_receipt"] is False
+ assert matrix["closed"] is False
+
+
def test_outdated_live_receipts_are_classified_stale_per_stage() -> None:
now = datetime(2026, 7, 11, 12, 0, tzinfo=UTC)
old = now - timedelta(hours=40)
@@ -342,6 +378,51 @@ def test_reconciliation_work_item_uses_same_trace_and_run_as_summary() -> None:
)
+def test_reconciliation_persistence_batches_candidate_queries_and_inserts() -> None:
+ source = inspect.getsource(_persist_reconciliation)
+ loop_start = source.index("for candidate_raw in candidates:")
+ loop_end = source.index("if insert_rows:")
+
+ assert "ANY(CAST(:candidate_fingerprints AS text[]))" in source
+ assert "insert_rows" in source
+ assert "await db.execute" not in source[loop_start:loop_end]
+ assert "AND output ->> 'closed' = 'true'" in source
+
+
+def test_reconciliation_loop_retries_degraded_result_before_daily_wait(
+ monkeypatch: Any,
+) -> None:
+ triggered_by_values: list[str] = []
+ sleep_values: list[int] = []
+
+ async def _fake_reconcile_once(*, triggered_by: str) -> dict[str, object]:
+ triggered_by_values.append(triggered_by)
+ if len(triggered_by_values) == 1:
+ return {"status": "degraded_no_write", "reason": "transient_db_pressure"}
+ return {"status": "success", "closed": True}
+
+ async def _fake_sleep(seconds: int) -> None:
+ sleep_values.append(seconds)
+ if len(sleep_values) == 3:
+ raise asyncio.CancelledError
+
+ monkeypatch.setattr(reconciliation_job, "reconcile_once", _fake_reconcile_once)
+ monkeypatch.setattr(reconciliation_job.asyncio, "sleep", _fake_sleep)
+ monkeypatch.setattr(reconciliation_job, "_FIRST_DELAY_SECONDS", 90)
+ monkeypatch.setattr(reconciliation_job, "_LOOP_BACKOFF_SECONDS", 1_800)
+ monkeypatch.setattr(reconciliation_job, "_seconds_until_next_trigger", lambda: 1)
+
+ try:
+ asyncio.run(reconciliation_job.run_asset_capability_reconciliation_loop())
+ except asyncio.CancelledError:
+ pass
+ else:
+ raise AssertionError("loop should stop at the test cancellation point")
+
+ assert triggered_by_values == ["startup", "retry"]
+ assert sleep_values == [90, 1_800, 1]
+
+
def test_priority_overlay_projects_aia_p0_006_runtime_controls() -> None:
matrix = build_asset_capability_matrix(
_single_host_scope(),
diff --git a/apps/api/tests/test_config_url_validation.py b/apps/api/tests/test_config_url_validation.py
index 27489672d..86269675b 100644
--- a/apps/api/tests/test_config_url_validation.py
+++ b/apps/api/tests/test_config_url_validation.py
@@ -172,3 +172,4 @@ def test_database_pool_budget_defaults_to_production_safe_values():
assert s.DATABASE_MAX_OVERFLOW == 0
assert s.DATABASE_POOL_TIMEOUT_SECONDS == 5.0
assert s.DATABASE_NULL_POOL is False
+ assert s.DATABASE_NULL_POOL_CONCURRENCY_LIMIT == 0
diff --git a/apps/api/tests/test_runtime_bootstrap_guards.py b/apps/api/tests/test_runtime_bootstrap_guards.py
index d42a70e7f..60b6f52df 100644
--- a/apps/api/tests/test_runtime_bootstrap_guards.py
+++ b/apps/api/tests/test_runtime_bootstrap_guards.py
@@ -8,6 +8,7 @@ Runtime bootstrap guard tests.
from __future__ import annotations
+import asyncio
import inspect
from collections.abc import Awaitable
from pathlib import Path
@@ -223,6 +224,52 @@ def test_get_engine_uses_null_pool_for_bounded_background_processes(monkeypatch)
assert "pool_timeout" not in captured
+@pytest.mark.asyncio
+async def test_null_pool_session_limiter_serializes_worker_db_sessions(
+ monkeypatch,
+) -> None:
+ from src.db import base as db_base
+
+ assert "async with _database_session_slot()" in inspect.getsource(
+ db_base.get_db
+ )
+ assert "async with _database_session_slot()" in inspect.getsource(
+ db_base.get_db_context
+ )
+ monkeypatch.setattr(db_base.settings, "DATABASE_NULL_POOL", True)
+ monkeypatch.setattr(
+ db_base.settings,
+ "DATABASE_NULL_POOL_CONCURRENCY_LIMIT",
+ 1,
+ )
+ monkeypatch.setattr(db_base.settings, "DATABASE_POOL_TIMEOUT_SECONDS", 1.0)
+ monkeypatch.setattr(db_base, "_null_pool_session_limiter", None)
+ monkeypatch.setattr(db_base, "_null_pool_session_limiter_size", 0)
+ first_entered = asyncio.Event()
+ release_first = asyncio.Event()
+ second_entered = asyncio.Event()
+
+ async def first_session() -> None:
+ async with db_base._database_session_slot():
+ first_entered.set()
+ await release_first.wait()
+
+ async def second_session() -> None:
+ async with db_base._database_session_slot():
+ second_entered.set()
+
+ first_task = asyncio.create_task(first_session())
+ await first_entered.wait()
+ second_task = asyncio.create_task(second_session())
+ await asyncio.sleep(0)
+
+ assert second_entered.is_set() is False
+
+ release_first.set()
+ await asyncio.gather(first_task, second_task)
+ assert second_entered.is_set() is True
+
+
@pytest.mark.asyncio
async def test_init_db_serializes_bootstrap_ddl_with_advisory_lock(monkeypatch):
from src.db import base as db_base
diff --git a/apps/api/tests/test_signal_worker_ansible_executor_binding.py b/apps/api/tests/test_signal_worker_ansible_executor_binding.py
index 21245814e..7eaf00e8a 100644
--- a/apps/api/tests/test_signal_worker_ansible_executor_binding.py
+++ b/apps/api/tests/test_signal_worker_ansible_executor_binding.py
@@ -21,6 +21,7 @@ def test_signal_worker_produces_candidates_and_owns_security_maintenance() -> No
assert "run_compliance_scanner_loop" in source
assert "run_coverage_evaluator_loop" in source
assert "run_asset_change_tracker_loop" in source
+ assert "run_asset_capability_reconciliation_loop" in source
assert "signal_worker_security_control_plane_maintenance_started" in source
assert "task.cancel()" in source
assert "await asyncio.gather(*security_maintenance_tasks" in source
@@ -33,6 +34,25 @@ def test_signal_worker_produces_candidates_and_owns_security_maintenance() -> No
assert "signal_worker_ansible_check_mode_loop_started" not in source
+def test_signal_worker_is_single_asset_capability_reconciliation_owner() -> None:
+ from src import main as api_main
+
+ api_source = inspect.getsource(api_main.lifespan)
+ worker_source = inspect.getsource(signal_worker._main)
+
+ assert "run_asset_capability_reconciliation_loop" not in api_source
+ assert "asset_capability_reconciliation_owner_delegated" in api_source
+ assert "run_asset_capability_reconciliation_loop" in worker_source
+ maintenance_start = worker_source.index("security_maintenance_loops =")
+ maintenance_end = worker_source.index(
+ "security_maintenance_tasks =", maintenance_start
+ )
+ assert (
+ "run_asset_capability_reconciliation_loop"
+ in worker_source[maintenance_start:maintenance_end]
+ )
+
+
def test_dedicated_broker_owns_ansible_executor_loop() -> None:
source = inspect.getsource(ansible_executor_broker._main)
@@ -71,6 +91,8 @@ def test_worker_manifest_has_no_execution_transport_or_write_loop() -> None:
assert worker_env["MCP_FEDERATION_STARTUP_SLEEP_SECONDS"] == "45"
assert worker_env["MCP_FEDERATION_SOURCE_TIMEOUT_SECONDS"] == "10"
assert worker_env["AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WINDOW_HOURS"] == "168"
+ assert worker_env["DATABASE_NULL_POOL"] == "true"
+ assert worker_env["DATABASE_NULL_POOL_CONCURRENCY_LIMIT"] == "1"
def test_execution_broker_is_only_workload_with_ssh_transport() -> None:
@@ -198,7 +220,10 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None:
assert "legacy cluster-wide executor binding still exists" in workflow
assert "awoooi-executor-boundary-verification" in workflow
assert "awoooi_executor_boundary_verification_v4" in workflow
- assert "single_writer_source_verifier=cd_tests_and_production_source_sha_v1" in workflow
+ assert (
+ "single_writer_source_verifier=cd_tests_and_production_source_sha_v1"
+ in workflow
+ )
assert "decision_manager_queue_only=true" in workflow
assert "webhook_router_queue_only=true" in workflow
assert "failure_watcher_queue_only=true" in workflow
@@ -206,24 +231,27 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None:
assert "candidate_idempotency_contract_verified=true" in workflow
assert "apply_idempotency_contract_verified=true" in workflow
assert "critical_break_glass_contract_verified=true" in workflow
- assert "independent_post_verifier_source_verifier=cd_tests_and_production_source_sha_v1" in workflow
+ assert (
+ "independent_post_verifier_source_verifier=cd_tests_and_production_source_sha_v1"
+ in workflow
+ )
assert "asset_postcondition_registry_complete=true" in workflow
assert "independent_post_verifier_read_only=true" in workflow
assert "executor_returncode_not_success_source=true" in workflow
assert "verifier_failure_marks_apply_failed=true" in workflow
assert "verifier_failure_retry_first=true" in workflow
assert "post_verifier_raw_output_not_persisted=true" in workflow
- assert "canonical_learning_source_verifier=cd_tests_and_production_source_sha_v1" in workflow
+ assert (
+ "canonical_learning_source_verifier=cd_tests_and_production_source_sha_v1"
+ in workflow
+ )
assert "canonical_playbook_resolver_complete=true" in workflow
assert "km_row_readback_required=true" in workflow
assert "playbook_row_readback_required=true" in workflow
assert "learning_failure_fail_closed=true" in workflow
assert "trust_failure_fail_closed=true" in workflow
assert "learning_receipts_public_safe=true" in workflow
- assert (
- "k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml"
- in workflow
- )
+ assert "k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml" in workflow
assert "executor_boundary_public_readback=verified_ready" in workflow
assert "EXECUTOR_BOUNDARY_READBACK_ATTEMPTS" in workflow
assert "range(1, attempts + 1)" in workflow
@@ -234,9 +262,7 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None:
assert "autonomous_single_writer_source_boundary" in workflow
assert "independent_post_verifier_source_boundary" in workflow
assert "canonical_learning_source_boundary" in workflow
- worker_env = worker_deployment["spec"]["template"]["spec"]["containers"][0][
- "env"
- ]
+ worker_env = worker_deployment["spec"]["template"]["spec"]["containers"][0]["env"]
assert any(item.get("name") == "AWOOOI_BUILD_COMMIT_SHA" for item in worker_env)
assert "k8s/awoooi-prod/08-deployment-worker.yaml" in workflow
assert '"source_boundary_verified"' in workflow
@@ -254,9 +280,18 @@ def test_cd_prunes_and_verifies_api_executor_boundary_in_production() -> None:
assert "current_user AS role_name" in workflow
assert "hashlib.sha256" in workflow
assert "representative_db_preflights_verified" in workflow
- assert 'api_required_connection_budget="$API_DB_REQUIRED_CONNECTION_BUDGET"' in workflow
- assert 'worker_required_connection_budget="$WORKER_DB_REQUIRED_CONNECTION_BUDGET"' in workflow
- assert 'broker_required_connection_budget="$BROKER_DB_REQUIRED_CONNECTION_BUDGET"' in workflow
+ assert (
+ 'api_required_connection_budget="$API_DB_REQUIRED_CONNECTION_BUDGET"'
+ in workflow
+ )
+ assert (
+ 'worker_required_connection_budget="$WORKER_DB_REQUIRED_CONNECTION_BUDGET"'
+ in workflow
+ )
+ assert (
+ 'broker_required_connection_budget="$BROKER_DB_REQUIRED_CONNECTION_BUDGET"'
+ in workflow
+ )
assert "api_representative_connection_probe_count" in workflow
assert "worker_representative_connection_probe_count" in workflow
assert "broker_representative_connection_probe_count" in workflow
@@ -330,7 +365,5 @@ def test_api_log_does_not_claim_skipped_executor_was_scheduled() -> None:
assert "awooop_ansible_check_mode_worker_schedule_evaluated" in main_source
assert "scheduled_in_api_process=scheduled_task is not None" in main_source
assert 'production_process_owner="awoooi-worker"' in main_source
- assert (
- 'production_process_owner="awoooi-ansible-executor-broker"' in main_source
- )
+ assert 'production_process_owner="awoooi-ansible-executor-broker"' in main_source
assert "awooop_ansible_candidate_backfill_worker_schedule_evaluated" in main_source
diff --git a/apps/api/tests/test_telegram_notification_egress_scanners.py b/apps/api/tests/test_telegram_notification_egress_scanners.py
index 3959d99d5..2156e6264 100644
--- a/apps/api/tests/test_telegram_notification_egress_scanners.py
+++ b/apps/api/tests/test_telegram_notification_egress_scanners.py
@@ -490,6 +490,7 @@ def test_agent99_has_no_direct_or_custom_telegram_sender_source() -> None:
)
assert "function Send-AgentTelegramRelay" not in source
assert "function Send-AgentTelegramPhotoDirect" not in source
- assert "canonical_telegram_gateway_transport_required" in source
- assert "providerSendPerformed = $false" in source
- assert 'routeStatus = "blocked_no_egress"' in source
+ assert "agent99/telegram-lifecycle" in source
+ assert '"X-Agent99-Lifecycle-Token" = $Token' in source
+ assert "durable_outbound_acknowledged" in source
+ assert "destination_binding_verified" in source
diff --git a/config/security/runtime-image-mirror-staging-policy.json b/config/security/runtime-image-mirror-staging-policy.json
index 5fff9af60..0242a73ed 100644
--- a/config/security/runtime-image-mirror-staging-policy.json
+++ b/config/security/runtime-image-mirror-staging-policy.json
@@ -80,6 +80,19 @@
"name": "local-path-provisioner",
"container": "local-path-provisioner"
}
+ },
+ {
+ "asset_id": "runtime-image:sealed-secrets-controller-0.26.0-canary",
+ "source_index_digest": "sha256:74cd190f424b591dd82a234685c8c81d50b33af807e03470bc12b23d202e0106",
+ "platform_digest": "sha256:1827b36853e124ac0dd2554f7cd55c04952ecb7c38ff19a4a8d93d633811ed68",
+ "source_config_digest": "sha256:bbfc8314cb4978b252fb313b84e1d51a7b0c040befd79ecc903ea952485a3348",
+ "push_ref": "registry.wooo.work/awoooi/runtime-mirror/sealed-secrets-controller:0.26.0-linux-amd64",
+ "workload": {
+ "kind": "deployment",
+ "namespace": "kube-system",
+ "name": "sealed-secrets-controller",
+ "container": "sealed-secrets-controller"
+ }
}
]
}
diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml
index c826f1171..3af3f9487 100644
--- a/k8s/awoooi-prod/06-deployment-api.yaml
+++ b/k8s/awoooi-prod/06-deployment-api.yaml
@@ -160,12 +160,12 @@ spec:
- name: AWOOOI_BUILD_COMMIT_SHA
# 2026-06-29 Codex: CD rewrites this to the deployed image tag so
# production deploy readback does not rely on a stale static snapshot.
- value: "ca49401c6af361b0128498c7355074538ebfe61e"
+ value: "0f2ddc0ce81ab52c7fbe33cb2e14dc54d0d33ed5"
- name: AWOOOI_DESIRED_API_IMAGE_TAG
# 2026-06-30 Codex: CD rewrites this alongside AWOOOI_BUILD_COMMIT_SHA.
# Production readback compares runtime image truth against this
# GitOps desired tag instead of doing a slow Gitea raw fetch.
- value: "ca49401c6af361b0128498c7355074538ebfe61e"
+ value: "0f2ddc0ce81ab52c7fbe33cb2e14dc54d0d33ed5"
- name: DATABASE_POOL_SIZE
# 2026-07-01 Codex: production role `awoooi` currently has a low
# connection limit. Keep API pool conservative until DB role
diff --git a/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml b/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml
index ec3737ae6..643fc8be7 100644
--- a/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml
+++ b/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml
@@ -66,7 +66,7 @@ spec:
- name: DATABASE_NULL_POOL
value: "true"
- name: AWOOOI_BUILD_COMMIT_SHA
- value: "ca49401c6af361b0128498c7355074538ebfe61e"
+ value: "0f2ddc0ce81ab52c7fbe33cb2e14dc54d0d33ed5"
- name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER
value: "false"
- name: ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER
diff --git a/k8s/awoooi-prod/08-deployment-worker.yaml b/k8s/awoooi-prod/08-deployment-worker.yaml
index f9510f202..edad2bbb6 100644
--- a/k8s/awoooi-prod/08-deployment-worker.yaml
+++ b/k8s/awoooi-prod/08-deployment-worker.yaml
@@ -31,9 +31,9 @@ spec:
strategy:
type: RollingUpdate
rollingUpdate:
- # 2026-07-01 Codex: DB role `awoooi` is capped at two connections and
- # worker imports the same API DB stack. Do not run old+new worker pods
- # during rollout until the DB role/pool budget is raised and verified.
+ # 2026-07-15 Codex: keep one worker owner during rollout. This prevents
+ # duplicate consumers and keeps the isolated worker DB role inside its
+ # verified connection budget while the old pod terminates.
maxSurge: 0
maxUnavailable: 1
template:
@@ -163,17 +163,21 @@ spec:
fieldRef:
fieldPath: metadata.name
- name: DATABASE_POOL_SIZE
- # 2026-07-01 Codex: keep worker DB usage inside the current
- # production role connection budget during reboot rollouts.
+ # NullPool releases each session; the explicit concurrency cap
+ # below preserves the intended one-connection worker budget.
value: "1"
- name: DATABASE_MAX_OVERFLOW
value: "0"
- name: DATABASE_NULL_POOL
value: "true"
+ - name: DATABASE_NULL_POOL_CONCURRENCY_LIMIT
+ # NullPool releases each connection after use, while this cap
+ # keeps concurrent background loops inside the worker role budget.
+ value: "1"
- name: DATABASE_BOOTSTRAP_DDL_ENABLED
value: "false"
- name: AWOOOI_BUILD_COMMIT_SHA
- value: "ca49401c6af361b0128498c7355074538ebfe61e"
+ value: "0f2ddc0ce81ab52c7fbe33cb2e14dc54d0d33ed5"
- name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER
value: "true"
- name: ENABLE_SECURITY_CONTROL_PLANE_MAINTENANCE_WORKER
diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml
index c0c28dda3..2d19b025a 100644
--- a/k8s/awoooi-prod/kustomization.yaml
+++ b/k8s/awoooi-prod/kustomization.yaml
@@ -40,9 +40,9 @@ resources:
# ⚠️ 重要: name 必須與 deployment YAML 中的 image 完全匹配 (含 tag)
# newName + newTag 由 CI 透過 kustomize edit set image 注入
images:
-- digest: sha256:ce9009bb35292ae029d6bb78de3c8b889be8b5d1f03d6f313b2bec2932621c50
+- digest: sha256:1d195016008e4f5ec795170055aca2194161f74fc44fb373d387f26417f0b4af
name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/api
-- digest: sha256:a5f5e6dfacaec46c5f94477460c6e9aed8db8f7ac93be6915d1ee3e930b74bda
+- digest: sha256:a1422caaefe56ce45d185cb85147ab3591e25251dc421cf738f1439565348c4d
name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/web
diff --git a/scripts/security/runtime_image_mirror_controller.py b/scripts/security/runtime_image_mirror_controller.py
index 0050f514a..4b441df17 100644
--- a/scripts/security/runtime_image_mirror_controller.py
+++ b/scripts/security/runtime_image_mirror_controller.py
@@ -603,9 +603,21 @@ def _registry_descriptor(image_ref: str) -> RegistryDescriptor | None:
)
-def _local_config_digest(image_ref: str) -> str:
- output = _run(["docker", "image", "inspect", "--format={{.Id}}", image_ref]).stdout
- return _require_digest((output or "").strip(), "local_config_digest")
+def _local_platform_config_digest(image_ref: str, platform: str) -> str:
+ output = _run(
+ [
+ "docker",
+ "image",
+ "inspect",
+ "--platform",
+ platform,
+ "--format={{.Id}}",
+ image_ref,
+ ]
+ ).stdout
+ return _require_digest(
+ (output or "").strip(), "local_platform_config_digest"
+ )
def _local_image_present(image_ref: str) -> bool:
@@ -675,14 +687,30 @@ def mirror_images(
if not _archive_contains_digest(archive, image.platform_digest):
raise ControllerError("runtime_cache_export_digest_missing")
_run(
- ["docker", "load", "--input", str(archive)],
+ [
+ "docker",
+ "load",
+ "--platform",
+ policy.platform,
+ "--input",
+ str(archive),
+ ],
quiet=True,
)
_run(
["docker", "tag", source_ref, image.push_ref],
quiet=True,
)
- _run(["docker", "push", image.push_ref], quiet=True)
+ _run(
+ [
+ "docker",
+ "push",
+ "--platform",
+ policy.platform,
+ image.push_ref,
+ ],
+ quiet=True,
+ )
finally:
_remove_local_image(image.push_ref)
if not source_preexisting:
@@ -763,17 +791,42 @@ def stage_images(
cache.export(source_ref, archive)
if not _archive_contains_digest(archive, image.platform_digest):
raise ControllerError("runtime_cache_export_digest_missing")
+ if not _archive_contains_digest(archive, source_config_digest):
+ raise ControllerError(
+ "runtime_cache_export_config_digest_missing"
+ )
_run(
- ["docker", "load", "--input", str(archive)],
+ [
+ "docker",
+ "load",
+ "--platform",
+ policy.platform,
+ "--input",
+ str(archive),
+ ],
quiet=True,
)
- if _local_config_digest(source_ref) != source_config_digest:
+ if (
+ _local_platform_config_digest(
+ source_ref, policy.platform
+ )
+ != source_config_digest
+ ):
raise ControllerError("local_image_config_digest_mismatch")
_run(
["docker", "tag", source_ref, image.push_ref],
quiet=True,
)
- _run(["docker", "push", image.push_ref], quiet=True)
+ _run(
+ [
+ "docker",
+ "push",
+ "--platform",
+ policy.platform,
+ image.push_ref,
+ ],
+ quiet=True,
+ )
finally:
_remove_local_image(image.push_ref)
if not source_preexisting:
diff --git a/scripts/security/tests/test_runtime_image_mirror_controller.py b/scripts/security/tests/test_runtime_image_mirror_controller.py
index 74d0efcb6..8107e2496 100644
--- a/scripts/security/tests/test_runtime_image_mirror_controller.py
+++ b/scripts/security/tests/test_runtime_image_mirror_controller.py
@@ -238,7 +238,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
self.assertEqual(policy.work_item_id, "AIA-P0-006-02B")
self.assertEqual(policy.risk_level, "high")
- self.assertEqual(len(policy.images), 5)
+ self.assertEqual(len(policy.images), 6)
by_asset = {image.asset_id: image for image in policy.images}
self.assertEqual(
set(by_asset),
@@ -247,6 +247,7 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
"runtime-image:local-path-provisioner-0.0.34-canary",
"runtime-image:metrics-server-0.8.1-canary",
"runtime-image:redis-8.2.3-canary",
+ "runtime-image:sealed-secrets-controller-0.26.0-canary",
"runtime-image:vpa-recommender-1.6.0-canary",
},
)
@@ -335,6 +336,32 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
self.assertEqual(local_path.workload.name, "local-path-provisioner")
self.assertEqual(local_path.workload.container, "local-path-provisioner")
self.assertEqual(local_path.workload.container_kind, "container")
+
+ sealed_secrets = by_asset[
+ "runtime-image:sealed-secrets-controller-0.26.0-canary"
+ ]
+ self.assertEqual(
+ sealed_secrets.source_index_digest,
+ "sha256:74cd190f424b591dd82a234685c8c81d50b33af807e03470bc12b23d202e0106",
+ )
+ self.assertEqual(
+ sealed_secrets.platform_digest,
+ "sha256:1827b36853e124ac0dd2554f7cd55c04952ecb7c38ff19a4a8d93d633811ed68",
+ )
+ self.assertEqual(
+ sealed_secrets.source_config_digest,
+ "sha256:bbfc8314cb4978b252fb313b84e1d51a7b0c040befd79ecc903ea952485a3348",
+ )
+ self.assertEqual(
+ sealed_secrets.runtime_repository, "sealed-secrets-controller"
+ )
+ self.assertEqual(sealed_secrets.workload.kind, "deployment")
+ self.assertEqual(sealed_secrets.workload.namespace, "kube-system")
+ self.assertEqual(sealed_secrets.workload.name, "sealed-secrets-controller")
+ self.assertEqual(
+ sealed_secrets.workload.container, "sealed-secrets-controller"
+ )
+ self.assertEqual(sealed_secrets.workload.container_kind, "container")
self.assertNotIn("github.com", raw)
self.assertNotIn("ghcr.io", raw)
self.assertIn('"external_pull_allowed": false', raw)
@@ -711,6 +738,28 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
)
)
+ def test_local_platform_config_digest_reads_image_id(self) -> None:
+ digest = "sha256:" + "1" * 64
+ completed = controller.subprocess.CompletedProcess(
+ args=[],
+ returncode=0,
+ stdout=digest + "\n",
+ stderr="",
+ )
+ with patch.object(controller, "_run", return_value=completed) as run:
+ self.assertEqual(
+ controller._local_platform_config_digest(
+ "source:tag", "linux/amd64"
+ ),
+ digest,
+ )
+
+ command = run.call_args.args[0]
+ self.assertEqual(command[:3], ["docker", "image", "inspect"])
+ self.assertIn("--platform", command)
+ self.assertIn("linux/amd64", command)
+ self.assertIn("--format={{.Id}}", command)
+
def test_target_digest_verifier_uses_internal_registry_transport(self) -> None:
image = controller.load_policy(POLICY_PATH).images[0]
with patch.object(controller.subprocess, "run") as run:
@@ -888,10 +937,12 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
side_effect=[None, descriptor],
),
patch.object(controller, "_local_image_present", return_value=False),
- patch.object(controller, "_archive_contains_digest", return_value=True),
+ patch.object(
+ controller, "_archive_contains_digest", return_value=True
+ ) as archive_contains,
patch.object(
controller,
- "_local_config_digest",
+ "_local_platform_config_digest",
return_value=image.source_config_digest,
),
patch.object(controller, "_registry_digest_present", return_value=True),
@@ -916,6 +967,11 @@ class RuntimeImageMirrorControllerTest(unittest.TestCase):
export.assert_called_once()
commands = [call.args[0] for call in run.call_args_list]
self.assertEqual([command[1] for command in commands], ["load", "tag", "push"])
+ self.assertEqual(archive_contains.call_count, 2)
+ self.assertIn("--platform", commands[0])
+ self.assertIn(policy.platform, commands[0])
+ self.assertIn("--platform", commands[2])
+ self.assertIn(policy.platform, commands[2])
self.assertTrue(all("pull" not in command for command in commands))
self.assertEqual(remove.call_count, 2)