Skip to content

fix(sessions): reject resuming a run whose accepted terminal output was not persisted - #4698

Merged
seratch merged 9 commits into
openai:mainfrom
Ayushraj06-bit:fix/terminal-output-session-append-recovery
Sep 7, 2026
Merged

seratch merged 9 commits into
openai:mainfrom
Ayushraj06-bit:fix/terminal-output-session-append-recovery

Conversation

@Ayushraj06-bit

@Ayushraj06-bit Ayushraj06-bit commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

When a resumed, approval-gated run ends via tool_use_behavior="stop_on_first_tool", the tool output becomes the terminal agent output. If the final client-managed Session.add_items() append fails, the exception propagates correctly but the resulting RunState cannot recover or explicitly reject that accepted terminal result.

Cause

The terminal batch is appended after the output guardrails so a tripwire can still redact it, which makes it the last fallible step of the run. For the whole of that append the run owns an accepted result, but nothing recorded that fact:

  • the exception escapes before run_state._current_step = None, and a terminal step serializes as current_step = null, so the snapshot looks like an ordinary resumable state;
  • no checkpoint marks that the final output, its output guardrails, and its terminal hooks had already completed.

Retrying the same live or JSON-restored state therefore re-enters the model, evaluates the guardrails against a new final output, and repeats on_agent_start / on_agent_end for a result the caller had already received.

Fix

RunState carries a single _terminal_unrecoverable marker, following the issue's explicit non-resumable contract:

  • Set after the final output, its output guardrails, and its terminal hooks have completed, immediately before the fallible Session append. This covers the ordinary terminal branches in both runners and the max-turns fallback, so every accepted final output has one boundary.
  • Serialized as terminal_unrecoverable on the unreleased 1.17 schema boundary. A snapshot carrying it on an older label is rejected rather than honored.
  • Rejects every later resume through reject_unrecoverable_terminal_state(), raised before resume_pending_session_write(), sandbox preparation, and any model, tool, guardrail, or lifecycle hook work, so the rejection has no side effects of its own.
  • Cleared only once the append and any post-append maintenance both return, so a compaction failure leaves the state closed rather than reopening it.
  • Carried into result-derived checkpoints, because a streamed result exists before its terminal append does and to_state() on a failed attempt would otherwise emit a resumable snapshot.

This is a fail-closed branch, not a recovery mode. The accepted output is not persisted, no result is reconstructed, and there is no settlement path, so the run reports its failure once and the caller starts a new run rather than silently paying for the tool twice.

Behavior

  • The first Session exception still propagates unchanged.
  • The tool side effect, the model call, and the terminal hooks each happen exactly once.
  • Every later resume fails closed with an actionable UserError, for both the atomic-failure and the commit-then-raise (lost acknowledgement) outcomes, and stays closed on repeated attempts.
  • A terminal turn that persists cleanly clears the marker, so ordinary runs are unaffected.
  • Live and JSON-restored states behave identically.

Alternatives considered

  • Recoverable terminal continuation (contract 1 in the issue), settling a losslessly persisted string output and reconstructing the result. Implemented first and rejected in review: it added terminal-output persistence, result reconstruction, string-only settlement, compaction-specific behavior, and new control flow in both runners for a narrow failure boundary.
  • Normalizing the terminal step to NextStepRunAgain, as the resumed handoff boundary now does after fix(sessions): recover resumed handoffs after session append failures #4725. Wrong here: under stop_on_first_tool the tool output is the answer, so replaying the model both changes an already accepted result and feeds that output back as model input.

Test plan

test_terminal_session_append_failure_rejects_every_later_resume covers the sync/stream, same-mode/cross-mode, live/JSON, fail-before-commit/commit-then-raise matrix (16 rows). Each row asserts the tool effect, the model call, and on_agent_end each happened exactly once during the failing attempt, then resumes twice and asserts both are rejected with none of those counts moving.

Alongside it:

  • test_unrecoverable_terminal_state_rejects_before_any_resumed_work patches Session.get_items and SandboxRuntime.prepare_agent to raise, proving the rejection precedes Session reconciliation and sandbox preparation.

  • test_failed_stream_result_checkpoint_keeps_the_terminal_marker takes a checkpoint from the failed streamed attempt, since a streamed result exists before its terminal append does, and asserts both it and its JSON round trip stay rejected.

  • test_max_turns_handler_output_is_marked_before_it_is_persisted covers the max-turns fallback, asserting the handler and its end hook each run exactly once across the failure and the rejected retry.

  • test_terminal_marker_is_cleared_once_the_turn_is_persisted guards the clear path.

  • test_terminal_marker_rejects_an_older_schema_label covers the schema boundary.

  • The test file on main: 21 failed, 69 passed. With this change: 90 passed.

  • make tests: 7722 passed / 147 skipped parallel, 77 passed / 4 skipped serial. The remaining failures and collection errors in this local environment are pre-existing on unpatched main (Windows symlink, tar, and mount sandbox tests, plus uninstalled optional extras), verified by running the identical suite against unpatched source and diffing the results.

  • make format, make lint, and make mypy are clean on all touched files.

  • make pyright was not run locally (Node is unavailable in this environment).

Issue number

Fixes #4690

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

…med append

A resumed turn that ends via tool_use_behavior appends its terminal batch
after the output guardrails, but never armed the pending_session_write
checkpoint for it, and NextStepFinalOutput was outside the steps RunState
can own or serialize. An append failure therefore left a state that could
neither recover nor reject the accepted result: retrying re-entered the
model, re-ran the guardrail, repeated the agent lifecycle hooks, and
returned a different final output while the Session could permanently lack
the tool call/output pair.

Let the resumed state own that batch when the accepted output is a plain
string, publish the passing output guardrail results before the append can
raise, and settle the same output on the next resume before any model call
or hook. Richer outputs cannot round-trip through the RunState codec
unchanged, so they keep the existing non-resumable behavior.

Fixes openai#4690

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d2958d9ec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run.py Outdated
Comment thread src/agents/run_internal/session_persistence.py Outdated
…hat is owed

Two review findings shared one cause: the terminal step was treated as
settleable on its own, and the check ran too late in the resume.

An accepted terminal output is durable only while its append checkpoint is
still outstanding. Once resume_pending_session_write() reconciles that batch,
later persistence work for it, such as compaction, can still fail, and the
cleared checkpoint made the leftover step look settled. A retry then reported
a completed run for an output whose required Session maintenance had failed.
Split the predicate so arming keeps using the step alone, while settling and
serialization also require an outstanding pending write, and capture it before
reconciling clears it.

The settle also ran after sandbox preparation, so retrying an already accepted
output needlessly created and cleaned up a provider sandbox, and a sandbox
startup failure could withhold an output that needed no further model or tool
work. Move it to the top of the run loop in both runners, ahead of input
guardrails and sandbox preparation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9b541f434

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run_internal/agent_runner_helpers.py Outdated
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9b541f434

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run.py Outdated
…alize path

Three review findings landed on the same rule, so this replaces it rather than
subtracting another case from it. The checkpoint replays one Session append, so
it may only be armed when that append really is all the run has left.

Ownership is now decided by terminal_checkpoint_owner() and opted into by the
caller, instead of being inferred from the step:

- Only the finalize path that saw every output guardrail succeed passes
  settle_terminal_output. The tripwire, guardrail-error, and max-turns saves
  reach the same helper and must not arm a checkpoint, because an error there
  has to surface on the next resume rather than be settled away.
- A compaction-aware Session also owes a deferred compaction for the batch,
  which the checkpoint does not carry. Those runs keep the existing
  non-resumable behavior instead of settling a batch whose maintenance the
  replay would skip.

resumed_write_owner() now resolves the owning state at the call sites, so
save_resumed_turn_items() forwards an already resolved owner and its signature
is unchanged.
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 06ff1f86cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 06ff1f86cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plain-string terminal recovery addresses the demonstrated failure, including the repeated agent lifecycle hooks. The remaining support boundary is not safe yet.

Structured/custom terminal outputs and compaction-aware Sessions are supported public paths, but the new tests explicitly leave them with the old non-resumable behavior. Retrying those states can still enter the model and run lifecycle hooks again after the original tool effect and terminal-output hooks already completed.

Please use one terminal checkpoint boundary for every post-acceptance Session append failure. A losslessly persisted string can settle the original output. If the output cannot round-trip losslessly, or post-append maintenance cannot be replayed safely, persist an explicit terminal-unrecoverable state and reject resume before sandbox preparation, agent/model/tool/guardrail work, or lifecycle hooks. This should be a fail-closed branch, not another recovery mode.

Ayushraj06-bit and others added 2 commits August 27, 2026 13:51
… restored

Structured and custom terminal outputs and compaction-aware Sessions are
supported public paths, but they were left with the old non-resumable
behavior. Retrying one of those states still entered the model and ran the
agent lifecycle hooks again, after the tool effect and the terminal-output
hooks had already completed once. That is the reported failure, only in the
cases the previous boundary excluded.

Every post-acceptance terminal append now takes the same checkpoint, and the
checkpoint records which of two outcomes a resume gets:

- a losslessly persisted string settles the original output, as before;
- anything else persists an explicit terminal-unrecoverable state. That covers
  an output the codec cannot round-trip and a batch whose post-append
  maintenance the replay cannot reproduce.

An unrecoverable state rejects the resume with an actionable UserError before
the pending Session write, sandbox preparation, and any agent, model, tool,
guardrail, or lifecycle hook work. It is a fail-closed branch rather than
another recovery mode, and it stays closed on every later resume because the
marker does not depend on the pending write.
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Thanks, that boundary was the wrong call and I have replaced it in a9de927.

You are right that leaving structured/custom outputs and compaction-aware Sessions on the old path was not safe. I had scoped them out as "not made worse", but they are supported public paths, and retrying one still re-entered the model and repeated on_agent_start / on_agent_end after the tool effect and terminal-output hooks had already run once. That is the reported failure, just in the cases the boundary excluded.

There is now one terminal checkpoint for every post-acceptance append, and the checkpoint records which of two outcomes a resume gets.

Settle. A losslessly persisted string settles the original output, unchanged from before.

Reject. Anything else persists an explicit terminal-unrecoverable state: an output the codec cannot round-trip, and a batch whose post-append maintenance the replay cannot reproduce. record_terminal_checkpoint() decides this at the acceptance point and replaces the step with TERMINAL_OUTPUT_UNRECOVERABLE, which serializes as {"type": "next_step_final_output", "data": {"unrecoverable": true}}.

Rejection raises an actionable UserError at the top of the resume, before the pending Session write, sandbox preparation, and any agent, model, tool, guardrail, or lifecycle hook work. It is fail-closed rather than another recovery mode, and it stays closed on every later resume because the marker does not depend on the pending write, so a committed append whose maintenance later failed cannot drift back into looking settled.

Guardrail failures still do not reach this boundary at all: only the finalize path that saw every output guardrail succeed opts in, so a tripwire or a raised guardrail surfaces on the next resume instead of being checkpointed over.

Tests

  • test_structured_terminal_output_rejects_resume_as_unrecoverable
  • test_compaction_aware_session_rejects_resume_as_unrecoverable
  • test_terminal_output_is_not_settled_after_failed_post_append_persistence
  • test_unrecoverable_terminal_state_rejects_before_any_run_work, which patches SandboxRuntime.prepare_agent to raise and asserts the rejection wins, with model calls, tool effects, guardrail evaluations, and hook counts all unchanged across the rejected resume.

Each covers sync and streamed, live and JSON-restored states. On main the file is 36 failed / 54 passed; on the previously reviewed 06ff1f8 it is 8 failed / 82 passed; here 90 passed. Full suite is 7684 passed / 147 skipped parallel and 77 passed / 4 skipped serial, rebased onto the branch after the merge from main, with make format, make lint, and make mypy clean.

One judgment call worth confirming: for compaction-aware Sessions I chose to reject rather than to carry response_id in the checkpoint and replay _defer_compaction() on resume. Replaying is the option that would keep those runs settleable, but it widens the checkpoint payload and the same gap exists for the run-again and interruption recovery paths from #4630, so it looked like separate work. Happy to do that instead if you would rather those Sessions settle than fail closed.

@Ayushraj06-bit
Ayushraj06-bit requested a review from seratch August 27, 2026 08:48
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Note

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The released bug is demonstrated, but I do not think we should merge the hybrid recovery design. It adds terminal-output persistence, result reconstruction, string-only settlement, compaction-specific behavior, and new control flow in both runners for a narrow failure boundary.

Please reset this to the issue's explicit non-resumable contract:

  • mark the RunState as terminal-unrecoverable after final output, output guardrails, and terminal hooks have completed but before the fallible Session append;
  • serialize that marker using the unreleased schema boundary;
  • reject every later resume before Session reconciliation, sandbox preparation, model calls, tools, guardrails, or hooks;
  • clear the marker only after the append and any post-append maintenance complete successfully.

This should remove the string-output settlement path, build_recovered_final_output_result, and the compaction-specific recovery branch. Please retain focused sync/stream and live/JSON tests proving that fail-before-commit and commit-then-raise never repeat lifecycle hooks or model work.

…ession-append-recovery

# Conflicts:
#	src/agents/run_internal/session_persistence.py
#	tests/test_run_impl_resume_paths.py
…as not persisted

Replaces the hybrid recovery design with the issue's explicit non-resumable
contract. Terminal-output persistence, result reconstruction, string-only
settlement, the compaction-specific branch, and the settlement control flow in
both runners are all gone.

A resumed turn that ends via tool_use_behavior appends its terminal batch after
the output guardrails, so the run owns an accepted result for the whole of that
fallible append. If it failed, the exception propagated but nothing recorded
that the output, its guardrails, and its terminal hooks had already completed,
so retrying the state ran the model again and repeated the agent lifecycle
hooks for a result the caller had already received.

RunState now carries a terminal_unrecoverable marker. It is set once the final
output, its output guardrails, and its terminal hooks have completed but before
the fallible Session append, serialized on the unreleased 1.17 schema boundary,
and cleared only after that append and any post-append maintenance both
succeed. While it is set, every resume is rejected with an actionable UserError
raised ahead of Session reconciliation, sandbox preparation, and any model,
tool, guardrail, or hook work.
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Reset to the non-resumable contract in ea43537, and rebuilt on top of current main so the conflicts with #4725 are gone.

The hybrid design is removed: no terminal-output persistence, no build_recovered_final_output_result, no string-only settlement, no compaction-specific branch, and no settlement control flow in either runner. session_persistence.py is back to untouched.

RunState now carries a single _terminal_unrecoverable marker:

  • Set after the final output, its output guardrails, and its terminal hooks have completed, immediately before the fallible Session append, in both runners.
  • Serialized as terminal_unrecoverable on the unreleased 1.17 boundary, and a snapshot carrying it on an older label is rejected rather than honored.
  • Rejects every later resume through reject_unrecoverable_terminal_state(), raised before resume_pending_session_write(), sandbox preparation, and any model, tool, guardrail, or hook work.
  • Cleared only after the append and any post-append maintenance both return, so a compaction failure leaves the state closed rather than open.

The source change is +62/-1 across four files, against +877/-30 before.

Tests

test_terminal_session_append_failure_rejects_every_later_resume covers the sync/stream, same-mode/cross-mode, live/JSON, fail-before-commit/commit-then-raise matrix (16 rows). Each row asserts the tool effect, the model call, and on_agent_end each happened exactly once during the failure, then resumes twice and asserts both are rejected with none of those counts moving.

Three focused cases alongside it:

  • test_unrecoverable_terminal_state_rejects_before_any_resumed_work patches Session.get_items and SandboxRuntime.prepare_agent to raise, proving the rejection precedes Session reconciliation and sandbox preparation.
  • test_terminal_marker_is_cleared_once_the_turn_is_persisted guards the clear path, so a terminal turn that persists cleanly is not left closed.
  • test_terminal_marker_rejects_an_older_schema_label covers the schema boundary.

On main the file is 19 failed / 69 passed; here 88 passed. Full suite 7720 passed / 147 skipped parallel and 77 passed / 4 skipped serial, with make format, make lint, and make mypy clean.

@Ayushraj06-bit Ayushraj06-bit changed the title fix(sessions): settle an accepted terminal output after a failed resumed append fix(sessions): reject resuming a run whose accepted terminal output was not persisted Aug 28, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea435370fb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run_internal/run_loop.py
Comment thread src/agents/run.py
@Ayushraj06-bit
Ayushraj06-bit requested a review from seratch August 28, 2026 14:28
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: ea435370fb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…turns output

Two paths reached an accepted final output without the fail-closed marker
following it.

A streamed result exists before its terminal append does, so a caller can take
a checkpoint from the failed attempt with `to_state()`.
`_populate_state_from_result()` copied the pending write and the current step
but not the marker, so that checkpoint looked like an ordinary resumable state
and skipped the rejection entirely.

The non-streamed max-turns handler produces a final output and runs its output
guardrails and end hooks, then persists through its own save callback. That
append was never marked, so retrying the state ran the handler and its hooks a
second time. The streamed max-turns path already inherited the marker from the
shared terminal finalizer.

Both now arm and clear the same marker around the same post-acceptance append,
so every accepted final output has one boundary.
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Both review findings on ea43537 are fixed in 4847f9d, each with a regression test that fails on the previous commit.

Preserve the terminal marker when snapshotting a failed stream. Confirmed: a streamed terminal append failure left the live state marked but streamed.to_state() returned a checkpoint with the marker cleared, so that snapshot was fully resumable and skipped the rejection. _populate_state_from_result() now forwards _terminal_unrecoverable alongside the pending write and the current step. test_failed_stream_result_checkpoint_keeps_the_terminal_marker takes the checkpoint from the failed stream and asserts both it and its JSON round trip are rejected.

Mark max-turn fallback output before persisting it. Confirmed: with a resumed state reaching a max_turns handler, the handler ran, on_agent_end fired with its output, the append failed, and retrying ran the handler and the hook a second time. _save_max_turns_handler_output() now arms and clears the same marker around its post-acceptance append. run_state stays out of the save itself so the resumed turn's persisted count is untouched, and it is bound as a default argument to match the other loop variables that callback already captures. test_max_turns_handler_output_is_marked_before_it_is_persisted asserts the handler and its end hook each run exactly once across the failure and the rejected retry.

The streamed max-turns path already inherited the marker from the shared terminal finalizer, so it needed no change.

Verification: the test file is 90 passed here and both new tests fail on ea43537. Full suite 7722 passed / 147 skipped parallel and 77 passed / 4 skipped serial, with make format, make lint, and make mypy clean on every touched file. Rebased-clean against current main.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4847f9d263

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run.py Outdated
… pass

finalize_max_turns_handler_output() drives the same save callback from its
guardrail-error path, so marking inside that callback closed the state for an
output that never passed its guardrails and never reached the caller. A failed
append there rejected every later resume, which is worse than the replay the
marker exists to prevent: the run had nothing to protect and no way forward.

Arm and clear at the success call site instead, which is only reachable once the
handler output cleared its guardrails and ran its end hooks. That matches the
streamed finalizer, which already wraps only its post-guardrail save, so both
runners now arm on the same condition.
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

Fixed in c31638d, with a regression test that fails on 4847f9d.

Confirmed exactly as described: with a max_turns handler and an output guardrail that raises a non-tripwire exception, the guardrail failed, the synthesized item's append failed, and the state was still marked. Every later resume was then rejected for an output that never passed its guardrails and never reached the caller — a worse outcome than the replay the marker exists to prevent, because the run had nothing to protect and no way forward.

The cause was arming inside _save_max_turns_handler_output(). That callback has two drivers: finalize_max_turns_handler_output() invokes it from its guardrail-error path, and run.py invokes it directly once the handler output has cleared its guardrails and run its end hooks. Only the second one carries an accepted result.

Arming moved to that success call site, and the callback is unmarked again. This matches the streamed finalizer, which already wraps only its post-guardrail save, so both runners now arm on the same condition. It is also the same rule already applied to the ordinary terminal branches, where the tripwire and guardrail-error saves deliberately arm nothing.

test_max_turns_guardrail_failure_leaves_the_state_retryable asserts the guardrail ran, the state carries no marker, and the retry surfaces the real guardrail exploded error rather than a fail-closed rejection. test_max_turns_handler_output_is_marked_before_it_is_persisted still covers the accepted path, so the two drivers are pinned apart.

Verification: 91 passed in the test file, full suite 7723 passed / 147 skipped parallel and 77 passed / 4 skipped serial, make format, make lint, and make mypy clean on every touched file.

@seratch seratch added this to the 0.22.x milestone Sep 7, 2026
@seratch
seratch merged commit e516331 into openai:main Sep 7, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Resumed terminal tool output cannot recover after a Session append failure

2 participants