[BUG] Failed-run recovery duplicates existing session history
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
mainat the commit above - SDK environments checked during diagnosis:
openai-agents==0.19.0/openai==2.53.0, andopenai-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.
Source: usestrix/strix