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/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_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/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/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_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