Files
2026-07-13 13:12:00 +08:00

557 lines
24 KiB
Python

"""Delivery-ambiguity classification and shared retry loop for native-forwarder
event POSTs.
The claude-native, codex-native, and antigravity-native forwarders mirror
transcript items into AP as ``external_conversation_item`` POSTs. The server
persists those with a random primary key and does NOT dedupe them — producers
are responsible for not re-posting items they have already sent. That makes a
blind retry after a failed POST unsafe: if the server committed the item and
published ``session.input.consumed`` but the response was lost, a retry appends
a second copy and the web UI renders a duplicate bubble. The native tmux pane
is unaffected, which is why the duplicate is web-only.
:func:`post_may_have_been_delivered` is the shared classifier all forwarders
use to decide whether a failed POST is safe to retry.
:func:`post_session_event_with_retry` is the shared retry loop extracted from
the codex/antigravity forwarders so a single implementation is maintained.
"""
from __future__ import annotations
import contextlib
import json
import logging
import time
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from pathlib import Path
import httpx
from omnigent._native_forwarder_health import (
note_post_success as note_native_post_success,
)
from omnigent._native_forwarder_health import (
record_post_failure as record_native_post_failure,
)
_logger = logging.getLogger(__name__)
# Dead-letter sink for permanently-undeliverable forward payloads (#1120).
_DEAD_LETTER_FILE = "dead_letter.jsonl"
# At this size the file rotates to a single .1 backup (keep-newest); disk ~2x this.
_DEAD_LETTER_MAX_BYTES = 50 * 1024 * 1024 # 50 MB per session
_DEAD_LETTER_BACKUP_FILE = _DEAD_LETTER_FILE + ".1"
def append_dead_letter(
bridge_dir: Path,
*,
session_id: str,
event_type: str,
payload: dict[str, object],
reason: str,
delivered_ambiguous: bool = False,
http_status: int | None = None,
transport_error: str | None = None,
) -> None:
"""
Append one undeliverable forward payload to ``{bridge_dir}/dead_letter.jsonl`` (#1120).
Write-only recovery artifact so a permanently-failed transcript/usage POST is
recoverable on disk instead of silently lost. Conservative startup replay of the
*proven-undelivered* records is layered on top (#1579) and reads the structured
classification fields below to decide what is safe to re-POST.
Best-effort: never raises (a dead-letter failure must not disrupt forwarding). When
the file reaches :data:`_DEAD_LETTER_MAX_BYTES` it is rotated to a single ``.1``
backup and a fresh file is started, so the most recent drops are kept (the oldest
rotate out); disk stays bounded at ~2x the cap.
:param bridge_dir: Native forwarder bridge directory the dead-letter file lives in.
:param session_id: Omnigent conversation id the dropped event targeted,
e.g. ``"conv_abc123"``.
:param event_type: Session event type that was dropped, e.g.
``"external_conversation_item"``.
:param payload: The event ``data`` payload that failed to deliver.
:param reason: Short human-readable cause, e.g.
``"permanent HTTP failure after retries"``.
:param delivered_ambiguous: Whether the failure was ambiguous (request sent,
response lost), so the server may have committed the item. Such records are
NEVER replayed — a re-POST risks a duplicate (no server-side dedup).
:param http_status: Final HTTP status code when the server responded, e.g.
``503`` or ``400``; ``None`` for a transport failure that saw no response.
:param transport_error: Transport-error class name when the POST raised without a
response, e.g. ``"ConnectError"``; ``None`` when the server responded.
:returns: None.
"""
try:
path = bridge_dir / _DEAD_LETTER_FILE
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
# Keep-newest: at the cap, rotate to a single .1 backup and start fresh.
if path.exists() and path.stat().st_size >= _DEAD_LETTER_MAX_BYTES:
path.replace(bridge_dir / _DEAD_LETTER_BACKUP_FILE)
# Log session_id, not path (logging a bridge path trips CodeQL's
# clear-text-sensitive-data heuristic; a bridge dir is not a secret).
_logger.warning(
"dead-letter file reached cap (%d bytes); rotated to %s and "
"started fresh (oldest dead-lettered forwards dropped): session=%s",
_DEAD_LETTER_MAX_BYTES,
_DEAD_LETTER_BACKUP_FILE,
session_id,
)
line = json.dumps(
{
"ts": time.time(),
"session_id": session_id,
"event_type": event_type,
"reason": reason,
"delivered_ambiguous": delivered_ambiguous,
"http_status": http_status,
"transport_error": transport_error,
"payload": payload,
}
)
with path.open("a", encoding="utf-8") as fh:
fh.write(line + "\n")
except Exception as exc: # noqa: BLE001 - dead-lettering must never disrupt forwarding.
_logger.warning(
"failed to dead-letter undeliverable forward: type=%s session=%s error=%r",
event_type,
session_id,
exc,
)
# Transport failures proving a POST never reached the server (no bytes
# sent) — safe to retry. See :func:`post_may_have_been_delivered`.
_DELIVERY_SAFE_RETRY_ERRORS = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.PoolTimeout,
)
def post_may_have_been_delivered(exc: httpx.HTTPError) -> bool:
"""
Return whether a failed AP POST may have been delivered AND
committed by the server despite the error — making a blind retry
unsafe for non-idempotent events.
- ``HTTPStatusError``: the server responded with a status. The
events route returns 2xx only after the item is appended and the
consume event is published, so any non-2xx means the item was not
committed (4xx rejects at parse time; a 5xx fails before/at the
append). No duplicate risk → safe to retry, so ``False``.
- Connection-establishment / pool-acquire failures
(:data:`_DELIVERY_SAFE_RETRY_ERRORS`): no bytes were sent → not
delivered → safe to retry, so ``False``.
- Any other transport error (read/write timeout, read/write error,
remote protocol error): the request was sent and we never saw a
response, so the server may have processed it → ambiguous →
``True``.
:param exc: HTTP exception raised while posting an AP event.
:returns: ``True`` when a retry could duplicate a server-committed
item; ``False`` when retrying is safe.
"""
if isinstance(exc, httpx.HTTPStatusError):
return False
if isinstance(exc, _DELIVERY_SAFE_RETRY_ERRORS):
return False
return True
async def post_external_session_status(
client: httpx.AsyncClient,
*,
session_id: str,
status: str,
output: str | None = None,
background_task_count: int | None = None,
response_id: str | None = None,
) -> None:
"""Post one ``external_session_status`` event to the Sessions API.
The turn-end edge native forwarders use to drive session status and, for
sub-agents, the parent-inbox wake. Shared by the claude-native and
cursor-native forwarders so the event shape stays in one place.
:param client: Omnigent HTTP client.
:param session_id: Omnigent session/conversation id.
:param status: Session status value, e.g. ``"idle"`` or ``"failed"``.
:param output: Optional text attached to ``data``. On a ``"failed"`` edge
the server surfaces it as ``last_task_error`` so the UI shows a detail
instead of a bare "failed". Ignored when falsy.
:param background_task_count: Background tasks (shells) still running at the
edge, forwarded so the UI can show "N background tasks still running".
``None`` omits the field (server leaves its sticky tally untouched) — the
default for edges that know nothing about background shells.
:param response_id: Optional id of the assistant turn this status edge
belongs to. When set, the server attaches it to the ``session.status``
SSE event so ap-web can drive the bubble's streaming lifecycle — that's
what makes native forwarded tool cards render LIVE (spinner + elapsed
timer) rather than as static completed cards. ``None`` (the default)
preserves the bare, turn-agnostic status edges (e.g. the sub-agent
quiescence badge) that don't map to a turn.
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
"""
data: dict[str, object] = {"status": status}
if output:
data["output"] = output
if background_task_count is not None:
data["background_task_count"] = background_task_count
if response_id is not None:
data["response_id"] = response_id
resp = await client.post(
f"/v1/sessions/{session_id}/events",
json={"type": "external_session_status", "data": data},
)
resp.raise_for_status()
async def post_session_event_with_retry(
*,
client: httpx.AsyncClient,
url: str,
payload: dict[str, object],
event_type: str,
max_attempts: int,
retry_status_codes: frozenset[int],
sleep: Callable[[float], Coroutine[None, None, None]],
retry_delay: Callable[[int], float],
logger_name: str,
) -> httpx.Response | None:
"""
POST a session event payload with bounded transient retries.
Shared retry loop used by the antigravity (and optionally other)
native forwarders. Conversation items persist with a random primary
key and no server-side dedup, so an ambiguous transport failure
(request sent, response lost) is NOT retried — a re-post would
duplicate the item. Other event types are idempotent/transient and
are retried.
:param client: HTTP client for Omnigent event posts.
:param url: Full request URL, e.g. ``"/v1/sessions/conv_x/events"``.
:param payload: JSON payload to POST, e.g. ``{"type": ..., "data": ...}``.
:param event_type: Session event type, e.g.
``"external_conversation_item"``. Used in log messages and to decide
whether an ambiguous failure is safe to retry.
:param max_attempts: Maximum POST attempts, e.g. ``3``.
:param retry_status_codes: HTTP status codes to retry, e.g.
``frozenset({429, 500, 503})``.
:param sleep: Async sleep coroutine (stubbable in tests).
:param retry_delay: Callable ``attempt -> float`` returning the delay
before the next attempt (one-based failed attempt number).
:param logger_name: Logger name used for warning messages, e.g.
``"omnigent.antigravity_native_reader"``.
:returns: Final HTTP response, or ``None`` when all attempts raised
transport errors (or after an ambiguous conversation-item failure).
"""
log = logging.getLogger(logger_name)
for attempt in range(1, max_attempts + 1):
try:
response = await client.post(url, json=payload)
except httpx.HTTPError as exc:
# Conversation items persist with a random primary key and no
# server-side dedup, so an ambiguous failure (request sent,
# response lost — the server may have committed it) must not
# be retried: a re-post would duplicate the item.
# Other event types are idempotent / transient, so retrying
# them on the same errors is safe and preserves delivery.
if event_type == "external_conversation_item" and post_may_have_been_delivered(exc):
log.warning(
"skipping session event after an ambiguous transport "
"failure (may already be committed); not retrying to avoid "
"a duplicate: type=%s error=%r",
event_type,
exc,
)
return None
if attempt >= max_attempts:
log.warning(
"failed to post session event after retries: type=%s attempts=%s error=%r",
event_type,
max_attempts,
exc,
)
# Surface this connectivity failure to the harness idle-turn
# watchdog so a stall caused by unreachable-server posts is
# reported with its real cause, not a generic "wedged LLM"
# reason.
record_native_post_failure(event_type, exc)
return None
await sleep(retry_delay(attempt))
continue
# Reaching here means the POST got an HTTP response (no transport
# error), proving the server is reachable — clear any stale
# connectivity-failure record so the watchdog can't later misattribute
# it to an unrelated stall.
note_native_post_success()
if response.status_code < 400:
return response
if response.status_code not in retry_status_codes:
return response
if attempt >= max_attempts:
return response
await sleep(retry_delay(attempt))
return None
@dataclass(frozen=True)
class RepostResult:
"""
Outcome of one dead-letter replay re-POST attempt (#1579).
Returned by the forwarder-supplied ``repost`` callable so
:func:`replay_dead_letters` can decide whether to drop the record or keep
it, and refresh its classification when a retained record's safety changed
(e.g. a proven-undelivered transport record that now fails *ambiguously*
must never be auto-replayed again).
:param delivered: ``True`` when the server accepted the re-POST (a sub-400
response). The record is removed on success.
:param delivered_ambiguous: ``True`` when the re-POST failed ambiguously
(request sent, response lost), so the item may now be committed. The
record is kept but reclassified so replay never touches it again.
:param http_status: Final HTTP status code when the server responded, or
``None`` for a transport failure that saw no response. Used to refresh
the retained record's classification.
"""
delivered: bool
delivered_ambiguous: bool = False
http_status: int | None = None
def _dead_letter_record_replayable(
record: object,
*,
retryable_status_codes: frozenset[int],
) -> bool:
"""
Return whether a dead-letter record is safe to re-POST on startup (#1579).
Only *proven-undelivered* records are replayable: a transport failure that
never reached the server, or a retryable status (e.g. ``503``) exhausted
after the forwarder's bounded retries. Ambiguous failures (the server may
have committed the item) and permanent rejections (a 4xx the server will
just reject again) are never replayed.
Records written before classification was added (#1579) lack the
``delivered_ambiguous`` field; they are treated as unsafe (forensic only)
so a pre-classification ambiguous drop is never replayed into a duplicate.
:param record: One parsed dead-letter record, or any non-dict entry
(malformed line) which is never replayable.
:param retryable_status_codes: HTTP statuses the forwarder treats as
transient/retryable, e.g. ``frozenset({429, 500, 503})``. A recorded
status in this set is proven-undelivered-but-recoverable.
:returns: ``True`` only for proven-undelivered records with the routing
fields (``session_id``, ``event_type``, ``payload``) needed to re-POST.
"""
if not isinstance(record, dict):
return False
session_id = record.get("session_id")
event_type = record.get("event_type")
if not (isinstance(session_id, str) and session_id):
return False
if not (isinstance(event_type, str) and event_type):
return False
if not isinstance(record.get("payload"), dict):
return False
if "delivered_ambiguous" not in record:
return False
if record.get("delivered_ambiguous"):
return False
http_status = record.get("http_status")
if http_status is None:
# Transport failure with no response (ambiguous already excluded above)
# — proven undelivered, so safe to re-POST.
return True
# The server responded: only a retryable status is recoverable; a permanent
# 4xx would just be rejected again.
return http_status in retryable_status_codes
def _read_dead_letter_entries(path: Path) -> list[object] | None:
"""
Read one dead-letter file into ordered entries, preserving malformed lines.
:param path: Dead-letter file path (current or ``.1`` backup).
:returns: Ordered entries — parsed ``dict`` records, or the raw ``str`` line
for any line that failed to parse (kept verbatim so a rewrite never
drops forensic data) — or ``None`` when the file does not exist.
"""
if not path.exists():
return None
entries: list[object] = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
entries.append(json.loads(stripped))
except json.JSONDecodeError:
entries.append(line)
return entries
def _rewrite_dead_letter_entries(path: Path, entries: list[object]) -> None:
"""
Atomically rewrite one dead-letter file with the retained entries (#1579).
Writes a sibling ``.tmp`` then ``os.replace``-es it into place so a crash
mid-rewrite never leaves a half-written file. An empty entry list removes
the file.
:param path: Dead-letter file path to rewrite.
:param entries: Retained entries in original order (``dict`` records are
re-serialized; raw ``str`` lines are written verbatim).
:returns: None.
"""
if not entries:
with contextlib.suppress(FileNotFoundError):
path.unlink()
return
lines = [entry if isinstance(entry, str) else json.dumps(entry) for entry in entries]
tmp = path.with_name(path.name + ".tmp")
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
tmp.replace(path)
async def replay_dead_letters(
bridge_dir: Path,
*,
repost: Callable[[dict[str, object]], Coroutine[None, None, RepostResult]],
retryable_status_codes: frozenset[int],
logger_name: str | None = None,
max_records: int | None = None,
deadline_seconds: float | None = None,
) -> int:
"""
Re-POST proven-undelivered dead-lettered forwards on forwarder startup (#1579).
Reads the ``.1`` backup first, then the current file, preserving append
order, and re-POSTs only *proven-undelivered* records (see
:func:`_dead_letter_record_replayable`) via the supplied ``repost``
callable. A delivered record is removed; a still-failing one is retained,
with its classification refreshed from the latest attempt so a record that
now fails ambiguously (or is permanently rejected) is never auto-replayed
again. Ambiguous and permanent-4xx records are left untouched as a forensic
record.
Bounded so a large dead-letter file or a slow/hung server cannot stall
startup: at most ``max_records`` records are re-POSTed and the whole drain
is abandoned once ``deadline_seconds`` elapses. Records left over by either
bound are retained unchanged (deferred to a later startup) and logged — never
silently dropped. The remaining latency lever, a short per-POST timeout and a
single attempt, is the caller's responsibility (via ``repost``).
Intended to run once at startup, before live forwarding begins, so no other
writer races the dead-letter files for this ``bridge_dir``.
:param bridge_dir: Native forwarder bridge directory holding the dead-letter
files.
:param repost: Async callable that re-POSTs one record's
``(session_id, event_type, payload)`` and returns a :class:`RepostResult`.
:param retryable_status_codes: HTTP statuses the forwarder treats as
transient/retryable, used to classify which recorded statuses are
recoverable.
:param logger_name: Optional logger name for the recovery summary line;
defaults to this module's logger.
:param max_records: Maximum number of records to re-POST this run, e.g.
``500``; ``None`` for no cap. Bounds the number of network POSTs (and
thus startup latency) even against a healthy server.
:param deadline_seconds: Wall-clock budget for the whole drain, e.g.
``30.0``; ``None`` for no deadline. Once exceeded, the remaining
replayable records are deferred to a later startup.
:returns: The number of records successfully replayed (and removed).
"""
log = logging.getLogger(logger_name) if logger_name else _logger
sources = [
bridge_dir / _DEAD_LETTER_BACKUP_FILE,
bridge_dir / _DEAD_LETTER_FILE,
]
loaded: list[tuple[Path, list[object]]] = []
any_replayable = False
for path in sources:
entries = _read_dead_letter_entries(path)
if entries is None:
continue
loaded.append((path, entries))
if any(
_dead_letter_record_replayable(entry, retryable_status_codes=retryable_status_codes)
for entry in entries
):
any_replayable = True
if not any_replayable:
# Nothing recoverable — leave the forensic files untouched.
return 0
replayed = 0
attempted = 0
deferred = 0
stop = False
deadline = time.monotonic() + deadline_seconds if deadline_seconds is not None else None
# ``loaded`` preserves the .1-then-current source order, so records replay
# in the order they were originally dropped.
for path, entries in loaded:
retained: list[object] = []
changed = False
for entry in entries:
if not _dead_letter_record_replayable(
entry, retryable_status_codes=retryable_status_codes
):
# Forensic record (ambiguous / permanent-4xx / malformed) — keep as is.
retained.append(entry)
continue
if stop:
deferred += 1
retained.append(entry)
continue
over_records = max_records is not None and attempted >= max_records
over_deadline = deadline is not None and time.monotonic() >= deadline
if over_records or over_deadline:
# Out of budget — defer this and every later replayable record
# to the next startup rather than stall here.
stop = True
deferred += 1
retained.append(entry)
continue
assert isinstance(entry, dict) # narrowed by _dead_letter_record_replayable
attempted += 1
result = await repost(entry)
if result.delivered:
replayed += 1
changed = True
continue
if (
entry.get("delivered_ambiguous") != result.delivered_ambiguous
or entry.get("http_status") != result.http_status
):
entry = {
**entry,
"delivered_ambiguous": result.delivered_ambiguous,
"http_status": result.http_status,
}
changed = True
retained.append(entry)
if changed:
_rewrite_dead_letter_entries(path, retained)
if replayed:
log.info(
"replayed %d proven-undelivered dead-lettered forward(s) on startup",
replayed,
)
if deferred:
log.info(
"deferred %d replayable dead-lettered forward(s) to a later startup "
"(replay budget reached: max_records=%s deadline_seconds=%s)",
deferred,
max_records,
deadline_seconds,
)
return replayed