From 2c2cc193e33f1c3f69a0b8b6091b137289012a3a Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Tue, 28 Jul 2026 12:43:39 +0000 Subject: [PATCH] fix: salvage a crashed run's full history into the session so revival loses no context --- strix/core/execution.py | 32 +++++++++++++++++++++++++++++++- tests/test_execution.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/strix/core/execution.py b/strix/core/execution.py index 46e07aac..ee2d1489 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -30,6 +30,7 @@ from strix.core.inputs import child_initial_input from strix.core.sessions import ( enforce_image_budget, open_agent_session, + replace_session_items, seed_initial_input, strip_all_images_from_session, ) @@ -117,6 +118,29 @@ def _transient_model_retry_delay(attempt: int) -> float: return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S) +async def _salvage_stream_to_session( + session: Session, + pre_run_items: list[Any], + stream: Any, + agent_id: str, +) -> None: + """Persist a crashed run's full history so a revived agent loses no context.""" + if stream is None: + return + try: + replay = list(stream.to_input_list()) + except Exception: + logger.exception("could not build salvage history for %s", agent_id) + return + desired = list(pre_run_items) + replay + if len(desired) <= len(pre_run_items): + return + try: + await replace_session_items(session, desired) + except Exception: + logger.exception("salvaging crashed run history failed for %s", agent_id) + + async def _seed_and_prepare_first_input( session: Session | None, initial_input: Any, *, start_parked: bool ) -> Any: @@ -534,6 +558,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 compactions = 0 model_retries = 0 while True: + stream: Any = None + pre_run_items: list[Any] = [] try: await coordinator.mark_running(agent_id) if session is not None: @@ -547,6 +573,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 await _compact_session(agent, session, run_config, force=False) except Exception: logger.exception("proactive compaction failed for %s", agent_id) + with contextlib.suppress(Exception): + pre_run_items = list(await session.get_items()) stream = Runner.run_streamed( agent, input=input_data, @@ -657,6 +685,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 if session is not None: input_data = [] continue + if session is not None: + await _salvage_stream_to_session(session, pre_run_items, stream, agent_id) if not interactive: raise if isinstance(exc, MaxTurnsExceeded): @@ -671,7 +701,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915 return None else: await _settle_run_result(coordinator, agent_id, interactive) - return stream + return cast("RunResultBase | None", stream) async def _settle_run_result( diff --git a/tests/test_execution.py b/tests/test_execution.py index 0c029e55..14d97410 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -595,6 +595,44 @@ async def test_run_cycle_parked_parks_instead_of_raising( assert coordinator.errors["root"] == "unexpected explosion" +class _SalvageStream: + def __init__(self, replay: list[dict[str, Any]]) -> None: + self._replay = replay + + def to_input_list(self) -> list[dict[str, Any]]: + return self._replay + + +@pytest.mark.asyncio +async def test_salvage_stream_to_session_preserves_full_history(tmp_path: Any) -> None: + session = SQLiteSession("child", tmp_path / "agents.db") + await session.add_items([{"role": "user", "content": "identity + task"}]) + pre_run = list(await session.get_items()) + + # A crash mid-run: the stream produced two turns the SDK never committed. + stream = _SalvageStream( + [ + {"role": "assistant", "content": "recon turn 1"}, + {"role": "assistant", "content": "recon turn 2"}, + ] + ) + await execution._salvage_stream_to_session(session, pre_run, stream, "child") + + stored = [cast("dict[str, Any]", i) for i in await session.get_items()] + assert [i["content"] for i in stored] == [ + "identity + task", + "recon turn 1", + "recon turn 2", + ] + + # A crash with nothing new to salvage leaves the session untouched. + await execution._salvage_stream_to_session( + session, list(await session.get_items()), _SalvageStream([]), "child" + ) + assert len(await session.get_items()) == 3 + session.close() + + @pytest.mark.asyncio async def test_seed_initial_input_persists_and_is_idempotent(tmp_path: Any) -> None: session = SQLiteSession("child", tmp_path / "agents.db")