A compression attempt that ends by host timeout, cooldown/backoff block, or gateway hygiene turn-hold writes its terminal activity stamp through _touch_activity without force_persist. The durable SessionDB activity projection is rate-limited to one write per SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS (60s), and the compression heartbeat had just written 'context compression in progress' — so the terminal stamp almost always lands inside the open window and is dropped. Nothing writes after it: the turn is over, the worker is detached, the host may be gone. sessions.last_activity_description stays 'context compression in progress' with last_activity_provenance 'agent.compression' forever, next to a compression_failure_error and a cooldown timestamp that prove the attempt terminated. Every surface reading the durable row (chat lists, session listings, reconnect, orphan reap, stall watchdog) shows a permanently stuck/compressing chat with no turn running. Fix at the single write path: _touch_activity bypasses the persist rate limit for any terminal compression provenance, exactly as force_persist does. The rate limit itself is unchanged for mid-compression heartbeats and every other writer, so no extra write pressure is added to the contended SessionDB path — one extra write per terminated compression attempt. TERMINAL_COMPRESSION_PROVENANCES is deliberately separate from conversation_compression._TERMINAL_COMPRESSION_PROVENANCES: that set answers 'may a detached heartbeat overwrite this stamp?' and excludes TURNHOLD because the worker may still be alive and adoptable. This set answers 'must this reach durable state now?', which is true for turn-hold too.
117 lines
5.4 KiB
Python
117 lines
5.4 KiB
Python
"""Shared session activity observation contract: timestamp + bounded description/provenance, observation
|
|
only (notification, timeout, kill and retry policy live elsewhere). Provenance is a small closed enum of
|
|
*noun* sources; the default agent clock stamps ``unknown`` unless a writer passes ``provenance=``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
from contextlib import suppress
|
|
from enum import Enum
|
|
from typing import Any, Mapping, Optional
|
|
|
|
ACTIVITY_DESCRIPTION_MAX = 120
|
|
|
|
# Durable SessionDB heartbeat cadence. Contract: MUST stay >= 30s — the SessionDB write path is contended and
|
|
# this observation-only projection never justifies extra write pressure. A code constant on purpose (no config
|
|
# can make it a high-frequency writer); matches the kanban auto-heartbeat. force_persist and terminal
|
|
# compression stamps (see TERMINAL_COMPRESSION_PROVENANCES) are the only bypasses.
|
|
SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS = 60.0
|
|
|
|
|
|
class ActivityProvenance(str, Enum):
|
|
"""Where a durable/in-memory activity stamp came from."""
|
|
|
|
UNKNOWN = "unknown"
|
|
# Compression writers: heartbeat, host timeout, cooldown, turn hold.
|
|
# See #72424.
|
|
AGENT_COMPRESSION = "agent.compression"
|
|
AGENT_COMPRESSION_TIMEOUT = "agent.compression_timeout"
|
|
AGENT_COMPRESSION_COOLDOWN = "agent.compression_cooldown"
|
|
AGENT_COMPRESSION_TURNHOLD = "agent.compression_turnhold"
|
|
|
|
|
|
# Provenances that END the user-visible "compressing" phase: the host's progress timeout, a cooldown/
|
|
# backoff block, and the gateway's hygiene turn-hold. Each is written once, at a terminal edge, and must
|
|
# reach the durable projection IMMEDIATELY — the heartbeat above wrote "context compression in progress"
|
|
# moments earlier, so a rate-limited terminal stamp leaves `sessions.last_activity_description` advertising
|
|
# a compression that has already stopped, with no later writer to correct it (the agent may be idle, the
|
|
# process gone). That is the permanently "stuck / compressing" chat users see.
|
|
#
|
|
# Distinct from ``conversation_compression._TERMINAL_COMPRESSION_PROVENANCES``, which answers a different
|
|
# question — "may a detached heartbeat still overwrite this stamp?" — and deliberately excludes TURNHOLD,
|
|
# because after a turn-hold the worker may still be alive and adoptable.
|
|
TERMINAL_COMPRESSION_PROVENANCES = frozenset(
|
|
{
|
|
ActivityProvenance.AGENT_COMPRESSION_TIMEOUT,
|
|
ActivityProvenance.AGENT_COMPRESSION_COOLDOWN,
|
|
ActivityProvenance.AGENT_COMPRESSION_TURNHOLD,
|
|
}
|
|
)
|
|
|
|
|
|
def is_terminal_compression_provenance(provenance: Optional[ActivityProvenance | str]) -> bool:
|
|
"""True when this stamp ends a compression phase and must bypass the persist rate limit."""
|
|
return normalize_activity_provenance(provenance) in TERMINAL_COMPRESSION_PROVENANCES
|
|
|
|
|
|
def bound_activity_description(description: Optional[str]) -> str:
|
|
"""Clamp free-form activity text to the shared description budget."""
|
|
text = (description or "").strip()
|
|
return text if len(text) <= ACTIVITY_DESCRIPTION_MAX else text[: ACTIVITY_DESCRIPTION_MAX - 1] + "…"
|
|
|
|
|
|
def normalize_activity_provenance(provenance: Optional[ActivityProvenance | str]) -> ActivityProvenance:
|
|
"""Return a known provenance, or ``UNKNOWN`` when unset/unrecognized."""
|
|
if isinstance(provenance, ActivityProvenance):
|
|
return provenance
|
|
try:
|
|
return ActivityProvenance((provenance or "").strip())
|
|
except ValueError:
|
|
return ActivityProvenance.UNKNOWN
|
|
|
|
|
|
def format_iteration_progress(api_call_count: Any, max_iterations: Any) -> str:
|
|
"""``iteration N/M`` for user-facing status lines, or ``iteration N`` when the cap is unbounded.
|
|
|
|
``AIAgent.max_iterations`` defaults to ``sys.maxsize`` (unlimited), so printing the pair verbatim
|
|
shows ``iteration 3/9223372036854775807`` in busy acks, heartbeats and timeout diagnostics (#102806).
|
|
"""
|
|
try:
|
|
cap = int(max_iterations)
|
|
except (TypeError, ValueError):
|
|
cap = sys.maxsize
|
|
if cap >= sys.maxsize:
|
|
return f"iteration {api_call_count}"
|
|
return f"iteration {api_call_count}/{cap}"
|
|
|
|
|
|
def reset_session_activity_persist_window(agent: Any) -> None:
|
|
"""Clear the persist rate-limit so the next stamp writes through (terminal compression labels must not stick on mid-compress text)."""
|
|
with suppress(Exception):
|
|
agent._session_activity_last_persist_mono = 0.0
|
|
|
|
|
|
def build_activity_snapshot(
|
|
*,
|
|
last_activity_at: Optional[float],
|
|
last_activity_description: Optional[str],
|
|
last_activity_provenance: Optional[ActivityProvenance | str] = None,
|
|
now: Optional[float] = None,
|
|
extra: Optional[Mapping[str, Any]] = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the shared activity snapshot (plus optional caller extras)."""
|
|
when = float(last_activity_at) if last_activity_at is not None else None
|
|
clock = float(now if now is not None else time.time())
|
|
desc = bound_activity_description(last_activity_description)
|
|
prov = normalize_activity_provenance(last_activity_provenance).value
|
|
return {
|
|
"last_activity_at": when,
|
|
"last_activity_description": desc,
|
|
"last_activity_provenance": prov,
|
|
"seconds_since_activity": round(clock - when, 1) if when is not None else None,
|
|
# Short aliases used by existing gateway/delegate readers.
|
|
"last_activity_ts": when, "last_activity_desc": desc, "description": desc, "provenance": prov,
|
|
**(extra or {}),
|
|
}
|