Skip to content
Merged
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
19 changes: 9 additions & 10 deletions loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1904,17 +1904,14 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None:
)
except CodexChatTimeoutError as exc:
event_buffer.close()
if execution_ended():
return
try:
adapter.interrupt_turn()
if not execution_ended():
adapter.interrupt_turn()
except Exception:
pass
self._fail_turn(session_id, turn_id, exc.error_code, str(exc), status="timed_out")
except CodexChatAgentError as exc:
event_buffer.close()
if execution_ended():
return
if (not loopx_execution and parse_native_goal_command(message) is None
and isinstance(adapter, CodexAppServerAdapter)
and exc.error_code in {"host_gate", "upstream_transport_failure", "server_overloaded"}
Expand All @@ -1923,8 +1920,6 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None:
routing_timeout=exc.native_routing_timeout,
)):
return True
if execution_ended():
return
self._fail_turn(session_id, turn_id, exc.error_code, str(exc), status="failed", gate=exc.gate)
if not adapter.healthcheck():
with self.lock:
Expand All @@ -1937,8 +1932,6 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None:
)
except Exception as exc: # noqa: BLE001 - preserve compact runtime failure.
event_buffer.close()
if execution_ended():
return
self._fail_turn(session_id, turn_id, "runtime_error", str(exc), status="failed")
finally:
event_buffer.close()
Expand Down Expand Up @@ -1992,6 +1985,7 @@ def _prepare_transport_retry(
adapters never decide whether to replay a model request.
"""
from .control_plane.chat_turn_acceptance import managed_turn_execution_allowed
from .control_plane.effect_runtime import EffectRuntimeRemoteError, EffectRuntimeStartupError

def current() -> bool:
with self.lock:
Expand Down Expand Up @@ -2032,7 +2026,8 @@ def current() -> bool:
# Stop/config changes during the final native read also win. A tool
# or new input appearing late cannot borrow the earlier proof.
return adapter.session.transport_retry_readback(original) == proof and current()
except (CodexChatAgentError, KeyError, OSError, TypeError, ValueError):
except (CodexChatAgentError, EffectRuntimeRemoteError, EffectRuntimeStartupError,
KeyError, OSError, TypeError, ValueError):
return False

def _fail_turn(
Expand All @@ -2046,6 +2041,10 @@ def _fail_turn(
gate: dict[str, Any] | None = None,
expected_statuses: set[str] | None = None,
) -> None:
# Failure recording has no provider effects. Its status CAS preserves a
# concurrent stop/completion, and claim release is pinned to this Turn.
# A new execution-eligibility RPC here can itself fail and strand the
# original error and every subsequent queued request.
completed = utc_now()
failed = self.store.update_turn(
session_id,
Expand Down
148 changes: 148 additions & 0 deletions tests/test_chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,154 @@
import pytest


@pytest.mark.parametrize("failure_kind", ["agent", "timeout", "runtime"])
@pytest.mark.parametrize("control_unavailable", [False, True])
def test_failed_worker_preserves_original_error_and_drains_followup(tmp_path, monkeypatch, failure_kind, control_unavailable):
from loopx.chat_store import ChatSessionStore
from loopx.control_plane import chat_turn_acceptance
from loopx.control_plane.effect_runtime import EffectRuntimeResponseAmbiguous

store = ChatSessionStore(tmp_path / "chat")
sid = store.create_session(goal_id="fixture", agent_id="codex",
adapter_kind="codex_app_server", upstream_thread_id="saved-thread")["session_id"]
first, _ = store.create_queued_turn(sid, client_turn_id="original", message="Keep original request.")
following, _ = store.create_queued_turn(sid, client_turn_id="following", message="Independent follow-up.")
runtime = chat_runtime.ChatRuntimeController(store=store, codex_bin="unused")
calls, failed = [], []
allowed = chat_turn_acceptance.managed_turn_execution_allowed

def checked(session, turn):
if control_unavailable and failed and turn["turn_id"] == first["turn_id"]:
raise EffectRuntimeResponseAmbiguous("chat.turn.execution_allowed", timeout=10)
return allowed(session, turn)

class Adapter:
upstream_thread_id = "saved-thread"

def capabilities(self):
return {}

def healthcheck(self):
return True

def interrupt_turn(self):
pass

def close_session(self):
pass

def start_turn(self, message, event_sink):
calls.append(message)
event_sink("turn.started", {"upstream_turn_id": "native-" + str(len(calls))})
if len(calls) == 1:
failed.append(True)
if failure_kind == "runtime":
raise ValueError("Original provider failure.")
error = chat_agent.CodexChatTimeoutError if failure_kind == "timeout" else chat_agent.CodexChatAgentError
raise error("Original provider failure.", gate=None, error_code="response_timeout" if failure_kind == "timeout" else "upstream_transport_failure")
return {"message": "Follow-up completed."}

adapter = Adapter()
monkeypatch.setattr(chat_turn_acceptance, "managed_turn_execution_allowed", checked)
monkeypatch.setattr(runtime, "_ensure_adapter", lambda *a, **kw: adapter)
try:
runtime._drain_session_queue(session_id=sid, work_dir=tmp_path, objective="Research")
result = store.load_turn(sid, first["turn_id"])
assert result["status"] == ("timed_out" if failure_kind == "timeout" else "failed")
assert result["error"] == "Original provider failure."
assert result["error_code"] == {"agent": "upstream_transport_failure", "timeout": "response_timeout", "runtime": "runtime_error"}[failure_kind]
assert store.load_turn(sid, following["turn_id"])["status"] == "completed"
assert store.load_session(sid)["active_turn_id"] is None
assert calls == ["Keep original request.", "Independent follow-up."]
assert sum(row["kind"] == "turn.failed" for row in store.events_after(sid, first["turn_id"], None)) == 1
assert [row["text"] for row in store.messages(sid) if row["role"] == "error"] == ["Original provider failure."]
finally:
runtime.close()


def test_transport_retry_refuses_unavailable_control_without_sending(tmp_path, monkeypatch):
from loopx.chat_store import ChatSessionStore
from loopx.control_plane import chat_turn_acceptance
from loopx.control_plane.effect_runtime import EffectRuntimeResponseAmbiguous

store = ChatSessionStore(tmp_path / "chat")
sid = store.create_session(goal_id="fixture", agent_id="codex",
adapter_kind="codex_app_server", upstream_thread_id="saved-thread")["session_id"]
turn, _ = store.create_turn(sid, client_turn_id="original", message="Keep original request.")
runtime = chat_runtime.ChatRuntimeController(store=store, codex_bin="unused")
adapter = SimpleNamespace(healthcheck=lambda: True)
runtime.adapters[sid] = adapter

def unavailable(*args):
raise EffectRuntimeResponseAmbiguous("chat.turn.execution_allowed", timeout=10)

monkeypatch.setattr(chat_turn_acceptance, "managed_turn_execution_allowed", unavailable)
try:
assert not runtime._prepare_transport_retry(sid, turn["turn_id"], adapter, {}, {"serverOverloaded": {}})
assert not any(row["kind"] == "transport.retry_planned" for row in store.events_after(sid, turn["turn_id"], None))
assert store.load_turn(sid, turn["turn_id"]) == turn
finally:
runtime.adapters.clear()
runtime.close()


@pytest.mark.parametrize("status", ["interrupting", "completed"])
@pytest.mark.parametrize("failure_kind", ["agent", "timeout", "runtime"])
def test_late_provider_failure_preserves_concurrent_stop_or_completion(tmp_path, monkeypatch, status, failure_kind):
from loopx.chat_store import ChatSessionStore
from loopx.control_plane import chat_turn_acceptance
from loopx.control_plane.effect_runtime import EffectRuntimeResponseAmbiguous

store = ChatSessionStore(tmp_path / "chat")
sid = store.create_session(goal_id="fixture", agent_id="codex",
adapter_kind="codex_app_server", upstream_thread_id="saved-thread")["session_id"]
turn, _ = store.create_turn(sid, client_turn_id="original", message="Keep original request.")
runtime = chat_runtime.ChatRuntimeController(store=store, codex_bin="unused")
allowed = chat_turn_acceptance.managed_turn_execution_allowed
preserved, effects = [], []

def checked(session, current):
if preserved:
raise EffectRuntimeResponseAmbiguous("chat.turn.execution_allowed", timeout=10)
return allowed(session, current)

def fail(message, event_sink):
if status == "interrupting":
store.begin_turn_interrupt(sid, turn["turn_id"], expected_statuses={"starting", "running"})
else:
store.update_turn(sid, turn["turn_id"], status="completing", response={"message": "Saved answer."})
store.finalize_managed_turn_completion(sid, turn["turn_id"])
preserved.append((store.session_snapshot(sid), store.load_turn(sid, turn["turn_id"])))
if failure_kind == "runtime":
raise ValueError("Late error.")
error = chat_agent.CodexChatTimeoutError if failure_kind == "timeout" else chat_agent.CodexChatAgentError
raise error("Late error.", gate=None)

adapter = SimpleNamespace(upstream_thread_id="saved-thread", start_turn=fail,
healthcheck=lambda: True, interrupt_turn=lambda: effects.append("cancel"))
monkeypatch.setattr(chat_turn_acceptance, "managed_turn_execution_allowed", checked)
try:
runtime._run_turn(session_id=sid, turn_id=turn["turn_id"], message=turn["message"], attachments=[], adapter=adapter)
after, (before, original_turn) = store.session_snapshot(sid), preserved[0]
# Closing the event buffer finishes activity timestamps, not ownership.
for view in (before, after):
for field in ("updated_at", "last_activity_at"):
view["session"].pop(field)
if view["active_turn"] is not None:
for field in ("first_event_at", "last_activity_at"):
view["active_turn"].pop(field)
assert after == before
observed_turn = store.load_turn(sid, turn["turn_id"])
for record in (original_turn, observed_turn):
for field in ("first_event_at", "last_activity_at"):
record.pop(field)
assert observed_turn == original_turn
assert not effects
assert not any(row["kind"] == "turn.failed" for row in store.events_after(sid, turn["turn_id"], None))
finally:
runtime.close()


@pytest.mark.parametrize("mode", ["chat", "execution", "structured", "review"])
def test_native_turn_preserves_operator_material(monkeypatch, tmp_path, mode):
material = " 修复下面的代码,保留字面值:\r\n```python\r\nif ready:\r\n\tprint('a b')\r\n```\n\nname:\u00a0value \n"
Expand Down
Loading