#1310·strix

[BUG] Failed-run recovery duplicates existing session history

Author: Genius229Created Sep 14, 2026Updated Sep 14, 2026

Describe the bug

_salvage_stream_to_session() prepends pre_run_items to stream.to_input_list(), although the SDK replay already includes the run's input history. Recovering a failed run therefore adds the existing history again.

This still reproduces on current main, 84f4108195fb516d48745aa912ba8862c7360ebb. Repeating recovery with no new events grows a two-item history to 4, 8, then 16 items. Function call IDs and their results are duplicated along with the other messages.

The relevant code is strix/core/execution.py:152-172:

replay = list(stream.to_input_list())
desired = list(pre_run_items) + replay
await replace_session_items(session, desired)

To reproduce

From a Strix development checkout, run the following offline reproduction. It creates a temporary SQLite database and never invokes an LLM, a scanner, or the example tool. The stub represents the SDK's full-history replay contract; the proposed regression tests also exercise a real Runner.run_streamed() failure and continuation using an in-memory HTTP transport.

uv run python - <<'PY'
import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace

from strix.core.execution import _salvage_stream_to_session
from strix.core.sessions import open_agent_session


async def main():
    with TemporaryDirectory() as tmp:
        session = open_agent_session("repro", Path(tmp) / "agents.db")
        try:
            await session.add_items([
                {
                    "type": "function_call", "call_id": "call_demo",
                    "name": "noop", "arguments": "{}",
                },
                {
                    "type": "function_call_output", "call_id": "call_demo",
                    "output": "ok",
                },
            ])
            counts = [len(await session.get_items())]
            for _ in range(3):
                before = list(await session.get_items())
                stream = SimpleNamespace(to_input_list=lambda: list(before))
                await _salvage_stream_to_session(session, before, stream, "repro")
                counts.append(len(await session.get_items()))
            print(counts)
        finally:
            session.close()


asyncio.run(main())
PY

Expected behavior

Before After Why
Repeated recovery produces [2, 4, 8, 16] without new events. Recovery should retain [2, 2, 2, 2]. A full SDK replay must not be prepended to the same persisted history.

Recovery should also retain incoming messages that were persisted after the pre-run snapshot. Simply replacing the session with replay can discard those messages; reconciliation must be atomic relative to SDK writes.

System information

  • OS: Linux x86_64
  • Python: 3.13.3
  • Strix: 1.6.2; also reproduced on main at the commit above
  • SDK environments checked during diagnosis: openai-agents==0.19.0 / openai==2.53.0, and openai-agents==0.19.4 / openai==2.54.0
  • LLM: none needed for reproduction; the original report used an OpenAI-compatible Chat Completions gateway

Additional context

The issue was investigated after repeated provider errors of the form:

openai.BadRequestError: Error code: 400
No tool output found for function call call_<redacted>.

The provider error alone does not establish the cause of the first failure. The independently reproduced bug here is that crash recovery duplicates the history and can compound an existing failure. This report does not claim to explain every occurrence of that provider error.

The existing salvage test supplies only newly generated messages from its stream stub, so it does not catch a full-history replay. Related lifecycle work includes #923 and #1025; neither addresses this duplication in current main.

I have a focused fix and regression tests ready to submit. The proposed scope is idempotent recovery, preservation of unambiguous late incoming messages, atomic SQLite reconciliation, and refusal to overwrite divergent history. It does not change model selection, invent missing tool results, or migrate old scan databases.