Skip to content

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

Open
Ayushraj06-bit wants to merge 7 commits into
openai:mainfrom
Ayushraj06-bit:fix/terminal-output-session-append-recovery
Open

fix(sessions): reject resuming a run whose accepted terminal output was not persisted#4698
Ayushraj06-bit wants to merge 7 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

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, in both runners.
  • 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.

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_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: 19 failed, 69 passed. With this change: 88 passed.

  • make tests: 7720 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
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
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
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
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
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
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".

# The output, its guardrails, and its terminal hooks are all complete, so from here until
# the turn is persisted this run owns a result no resume can reproduce.
if streamed_result._state is not None:
streamed_result._state._terminal_unrecoverable = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the terminal marker when snapshotting a failed stream

When a streamed terminal Session append fails, callers can obtain the documented recovery checkpoint via failed_result.to_state(). This assignment marks only streamed_result._state, but _populate_state_from_result() copies the pending write and current step without copying _terminal_unrecoverable; the emitted checkpoint therefore loses the fail-closed marker. Retrying that checkpoint reconciles the append and re-enters the completed terminal step, repeating hooks/tool work or producing a new model result. Forward the marker into the result-derived state and cover the failed-stream-result checkpoint path.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

Comment thread src/agents/run.py
Comment on lines +1375 to +1376
if run_state is not None:
run_state._terminal_unrecoverable = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Mark max-turn fallback output before persisting it

For a resumed run that reaches a configured max_turns handler, the handler produces a final output, runs end hooks and output guardrails, then its non-streamed save callback still calls save_final_turn_items_after_guardrails(..., run_state=None) at run.py:1532. Unlike the ordinary terminal branches marked here, an append failure leaves the supplied RunState unmarked and retrying it runs the max-turn handler and its hooks again. Arm and clear the same terminal marker around that fallback's post-acceptance persistence path.

AGENTS.md reference: AGENTS.md:L147-L147

Useful? React with 👍 / 👎.

@Ayushraj06-bit
Ayushraj06-bit requested a review from seratch August 28, 2026 14:28
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