Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,12 +925,15 @@ async def on_processing_complete(
async def _end_webhook_session(
self, event: "MessageEvent", session_chat_id: str
) -> None:
"""Mark the per-delivery webhook session ended in state.db.
"""End the per-delivery session and evict its cached agent.

Resolves the persisted ``session_id`` from the gateway session store
using the SAME source the run was keyed on (so profile multiplexing
and key construction match exactly), then closes it via the existing
``SessionDB.end_session`` API — never a hand-written UPDATE.
``SessionDB.end_session`` API — never a hand-written UPDATE. Webhook
delivery IDs make these sessions one-shot, so retaining their agents
cannot provide a cache hit and keeps provider runtimes alive until the
general cache cap or idle sweep runs.
"""
runner = self.gateway_runner
if runner is None:
Expand All @@ -939,6 +942,7 @@ async def _end_webhook_session(
store = getattr(runner, "session_store", None)
if session_db is None or store is None:
return
session_key = None
try:
key_fn = getattr(runner, "_session_key_for_source", None)
if key_fn is None:
Expand Down Expand Up @@ -985,6 +989,18 @@ async def _end_webhook_session(
session_chat_id,
e,
)
finally:
if session_key:
evict = getattr(runner, "_evict_cached_agent", None)
if callable(evict):
try:
evict(session_key)
except Exception as e:
logger.debug(
"[webhook] Failed to evict cached agent for %s: %s",
session_chat_id,
e,
)

# ------------------------------------------------------------------
# Signature validation
Expand Down
27 changes: 23 additions & 4 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3828,6 +3828,17 @@ def _sync_external_memory_for_turn(
except Exception:
pass

def _close_codex_session(self) -> None:
"""Detach and close the Codex app-server session, if one is active."""
codex_session = getattr(self, "_codex_session", None)
self._codex_session = None
if codex_session is None:
return
try:
codex_session.close()
except Exception:
logger.debug("Failed to close Codex app-server session", exc_info=True)

def release_clients(self) -> None:
"""Release LLM client resources WITHOUT tearing down session tool state.

Expand Down Expand Up @@ -3866,6 +3877,11 @@ def release_clients(self) -> None:
except Exception:
pass

# A rebuilt agent cannot reuse this instance's Codex thread. Closing
# it here prevents cache eviction from orphaning the app-server and
# its MCP subprocesses.
self._close_codex_session()

# Retire the OpenAI/httpx client to release sockets immediately.
# #70773: eviction runs on the gateway's memory-manager thread — a
# cross-thread hard close of the shared client can release TLS FDs
Expand Down Expand Up @@ -3934,7 +3950,10 @@ def close(self) -> None:
except Exception:
pass

# 5. Close the OpenAI/httpx client
# 5. Close the Codex app-server runtime owned by this agent.
self._close_codex_session()

# 6. Close the OpenAI/httpx client
try:
client = getattr(self, "client", None)
if client is not None:
Expand All @@ -3943,14 +3962,14 @@ def close(self) -> None:
except Exception:
pass

# 5b. Close the cached per-request wire client (reused across
# 6b. Close the cached per-request wire client (reused across
# sequential LLM calls; see _create_request_openai_client).
try:
self._close_cached_request_openai_client(reason="agent_close")
except Exception:
pass

# 6. Free conversation history. Mirrors _release_evicted_agent_soft's
# 7. Free conversation history. Mirrors _release_evicted_agent_soft's
# soft-eviction clear — close() is the hard teardown for true session
# boundaries (/new, /reset, session expiry), so the message list won't
# be reused. Drops the reference proactively rather than waiting for
Expand All @@ -3961,7 +3980,7 @@ def close(self) -> None:
except Exception:
pass

# 7. Finalize the owned SQLite session row unless this agent is only a
# 8. Finalize the owned SQLite session row unless this agent is only a
# temporary helper that deliberately handed session ownership forward
# (manual compression helpers that rotate to a continuation session_id,
# or background-review forks that share the live parent's session_id and
Expand Down
13 changes: 13 additions & 0 deletions tests/gateway/test_webhook_session_close.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,14 @@ class _FakeRunner:
def __init__(self, store: SessionStore):
self.session_store = store
self._session_db = store._db
self.evicted_session_keys = []

def _session_key_for_source(self, source: SessionSource) -> str:
return self.session_store._generate_session_key(source)

def _evict_cached_agent(self, session_key: str) -> None:
self.evicted_session_keys.append(session_key)


def _make_store(tmp_path) -> SessionStore:
sessions_dir = tmp_path / "sessions"
Expand Down Expand Up @@ -144,6 +148,9 @@ async def _message_handler(event: MessageEvent):
"prune_sessions can never reap it (the ghost-session leak)"
)
assert row["end_reason"] == "webhook_complete"
assert runner.evicted_session_keys == [
runner._session_key_for_source(event.source)
]

# And the closed row is actually prunable, unlike the pre-fix leak.
pruned = store._db.prune_sessions(older_than_days=0, source="webhook")
Expand Down Expand Up @@ -184,6 +191,9 @@ async def _boom(event: MessageEvent):
"on the error path"
)
assert row["end_reason"] == "webhook_complete"
assert runner.evicted_session_keys == [
runner._session_key_for_source(event.source)
]
store._db.close()


Expand All @@ -209,4 +219,7 @@ async def test_end_webhook_session_awaits_async_session_db(tmp_path):
row = store._db.get_session(entry.session_id)
assert row["ended_at"] is not None
assert row["end_reason"] == "webhook_complete"
assert runner.evicted_session_keys == [
runner._session_key_for_source(event.source)
]
store._db.close()
45 changes: 44 additions & 1 deletion tests/run_agent/test_codex_app_server_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,50 @@ def test_api_mode_is_codex_app_server(self):
assert agent.api_mode == "codex_app_server"


class TestCodexSessionLifecycle:
def test_release_clients_closes_and_detaches_codex_session(self):
agent = _make_codex_agent()
session = MagicMock()
agent._codex_session = session

agent.release_clients()

session.close.assert_called_once_with()
assert agent._codex_session is None

def test_close_closes_and_detaches_codex_session(self):
agent = _make_codex_agent()
session = MagicMock()
agent._codex_session = session

agent.close()

session.close.assert_called_once_with()
assert agent._codex_session is None

def test_repeated_cleanup_closes_codex_session_once(self):
agent = _make_codex_agent()
session = MagicMock()
agent._codex_session = session

agent.release_clients()
agent.close()

session.close.assert_called_once_with()
assert agent._codex_session is None

def test_cleanup_detaches_codex_session_when_close_raises(self):
agent = _make_codex_agent()
session = MagicMock()
session.close.side_effect = RuntimeError("close failed")
agent._codex_session = session

agent.release_clients()

session.close.assert_called_once_with()
assert agent._codex_session is None


class TestRunConversationCodexPath:
def test_run_conversation_returns_codex_shape(self, fake_session):
agent = _make_codex_agent()
Expand Down Expand Up @@ -786,4 +830,3 @@ def fake_run_turn(self, user_input, **kwargs):

assert "on_event" in captured_init and captured_init["on_event"] is not None
assert ("tool.started", "exec_command", "pytest") in events