#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.
- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
`Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
these quotes alone and only strips the two that are genuinely redundant, which this commit
strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
96 lines
4.6 KiB
Python
96 lines
4.6 KiB
Python
"""#100531 — the codex app-server thread survives an AIAgent rebuild (API-server restart, per-request agents).
|
|
|
|
``CodexAppServerSession`` keeps the codex thread id in memory only, so every new ``AIAgent`` for the same
|
|
Hermes session used to ``thread/start`` an empty thread while Hermes' own transcript continued. The runtime
|
|
now publishes ``codex_thread_id`` into the session row's ``model_config`` once the turn's projected rows are
|
|
durable, the next agent for that session issues ``thread/resume`` for it, and a stored id codex cannot hand
|
|
back fails closed: fresh thread, binding dropped, one status-rail notice.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from agent.transports import codex_app_server_session as session_mod
|
|
from agent.transports.codex_app_server import CodexAppServerError
|
|
from agent.transports.codex_app_server_session import CodexAppServerSession, TurnResult
|
|
from hermes_state import SessionDB
|
|
|
|
SID = "sess-codex-restart"
|
|
|
|
|
|
class _WireClient:
|
|
"""Minimal app-server stand-in: answers thread/start with a fresh id, thread/resume with the requested
|
|
id (or refuses ids in ``dead``), and records every JSON-RPC method it saw."""
|
|
|
|
dead: set[str] = set()
|
|
instances: "list[_WireClient]" = []
|
|
counter = 0
|
|
|
|
def __init__(self, **kwargs):
|
|
self.requests: list[tuple[str, dict]] = []
|
|
_WireClient.instances.append(self)
|
|
|
|
def initialize(self, **kwargs):
|
|
return {}
|
|
|
|
def request(self, method, params=None, timeout=30.0):
|
|
params = params or {}
|
|
self.requests.append((method, params))
|
|
if method == "thread/resume":
|
|
if params["threadId"] in _WireClient.dead:
|
|
raise CodexAppServerError(code=-32600, message=f"no rollout found for thread id {params['threadId']}")
|
|
return {"thread": {"id": params["threadId"]}}
|
|
_WireClient.counter += 1
|
|
return {"thread": {"id": f"thread-{_WireClient.counter}"}}
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def _agent(db, **kwargs):
|
|
from run_agent import AIAgent
|
|
agent = AIAgent(api_key="stub", base_url="https://stub.invalid", provider="openai", api_mode="codex_app_server",
|
|
quiet_mode=True, skip_context_files=True, skip_memory=True, session_db=db, session_id=SID, **kwargs)
|
|
agent._spawn_background_review = lambda **kw: None
|
|
return agent
|
|
|
|
|
|
def _run_turn(self, user_input, **kwargs):
|
|
return TurnResult(final_text=f"echo {user_input}", thread_id=self._thread_id, turn_id="turn-1",
|
|
projected_messages=[{"role": "assistant", "content": f"echo {user_input}"}])
|
|
|
|
|
|
def test_rebuilt_agent_resumes_the_stored_codex_thread_and_an_unresumable_one_fails_closed(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(session_mod, "CodexAppServerClient", _WireClient)
|
|
monkeypatch.setattr(CodexAppServerSession, "run_turn", _run_turn)
|
|
_WireClient.instances, _WireClient.dead, _WireClient.counter = [], set(), 0
|
|
db = SessionDB(Path(tmp_path) / "state.db")
|
|
try:
|
|
# Process 1: first turn publishes the binding only after the transcript is durable.
|
|
first = _agent(db)
|
|
assert first.run_conversation("Remember the word amber.")["completed"]
|
|
assert db.get_session_model_config_value(SID, "codex_thread_id") == "thread-1"
|
|
assert [r["content"] for r in db.get_messages(SID)][-1] == "echo Remember the word amber."
|
|
|
|
# Process 2 (restart): a new AIAgent for the same session resumes that thread before turn/start.
|
|
notices: list[str] = []
|
|
def status_callback(kind, message): # the lifecycle rail every surface renders; other kinds are noise
|
|
if kind == "lifecycle":
|
|
notices.append(str(message))
|
|
second = _agent(db)
|
|
second.status_callback = status_callback
|
|
assert second.run_conversation("Which word?", conversation_history=[])["completed"]
|
|
assert [m for m, _ in _WireClient.instances[1].requests] == ["thread/resume"]
|
|
assert _WireClient.instances[1].requests[0][1]["threadId"] == "thread-1"
|
|
assert notices == []
|
|
|
|
# Control: the stored thread is gone on the codex side -> fresh thread, binding rotated, one notice.
|
|
_WireClient.dead = {"thread-1"}
|
|
third = _agent(db)
|
|
third.status_callback = status_callback
|
|
assert third.run_conversation("And now?", conversation_history=[])["completed"]
|
|
assert [m for m, _ in _WireClient.instances[2].requests] == ["thread/resume", "thread/start"]
|
|
assert db.get_session_model_config_value(SID, "codex_thread_id") == "thread-2"
|
|
assert notices == ["Codex thread could not be resumed; starting a new one."]
|
|
finally:
|
|
db.close()
|