Skip to content

fix(responses): refuse a continuation whose task scope does not match - #5130

Merged
lidge-jun merged 8 commits into
devfrom
codex/continuation-scope-replay
Sep 19, 2026
Merged

lidge-jun merged 8 commits into
devfrom
codex/continuation-scope-replay

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • A continuation whose client task scope does not match its stored state is now refused instead of silently becoming a fresh conversation. The mismatch already refused to replay another task's history — which is correct — but then stripped previous_response_id and continued with whatever input the request carried. When that input was only a delta, the model answered without the history the client believed it had, and nothing told the client that had happened.
  • The refusal is the generic previous_response_not_found 400 already used for missing or corrupt state, byte for byte, returned before any upstream send. The client's remedy is the same one that path already documents: resend the full conversation without previous_response_id.
  • It refuses even when the supplied input looks complete, because this process cannot prove that it is. A caller that really does hold the whole conversation retries without the ID and succeeds. Accepting an apparently-complete input would make the guarantee depend on a heuristic over client data.
  • The response reveals neither the stored scope nor whether foreign state exists. The internal reason stays internal, and the diagnostic log line carries no task identifier or state content.
  • Same-scope continuation is unchanged. Two absent or blank scopes remain the legacy unscoped cohort and still replay together; every transition between scoped and unscoped, and between two different scopes, is a mismatch.
  • Closes [Bug]: Request full replay when a task-scoped continuation cannot be used #5124.

This deliberately changes the historical fresh-start behavior. That behavior was intentional when task scoping was introduced; this applies the fail-closed replay principle the rest of the continuation path already follows. The change is recorded in the Responses transport contract and in the client-facing integration guides, English and Korean.

Scope map:

Path Change
src/responses/state.ts A scope mismatch records scope_mismatch replay provenance and returns the original body; the fresh-request WeakSet and its helper are gone.
src/server/responses/request-prepare.ts, core-combo.ts One replay-failure read; the mismatch reason logs a content-free warning and returns the existing generic 400.
tests/responses/responses-state.test.ts, tests/server/server-combo-failover-e2e.test.ts, tests/codex-integration/issue-702-expired-replay-state.test.ts The deliberate fresh-start cases become refusal cases; HTTP and combo agreement and zero upstream sends are asserted.
structure/transports/responses.md, docs-site guides (en, ko) Record the policy and the client's remedy.

Ratchet-tracked files all moved down or stayed within cap: src/responses/state.ts 1,352 → 1,345 (cap 1,371); tests/responses/responses-state.test.ts 3,983 → 3,982 (cap 3,983); tests/server/server-combo-failover-e2e.test.ts 4,152 → 4,165 (cap 4,166).

Verification

  • Local checks: NOT RUN. This lane is prohibited from running local suites, focused tests, typecheck, builds, installs or any live ocx command, so no local result is claimed. Exact-head hosted CI on this PR is the executable evidence.
  • Static review performed instead: traced every consumer of expandPreviousResponseInput and of the replay-failure shape, including the WebSocket entrypoint, which reuses the same handler and reframes a non-2xx response; confirmed the refusal reuses the identical formatErrorResponse call already used for unavailable state, so the wire bytes are unchanged; confirmed the scoped/unscoped cohort test is absence-versus-presence after trimming, so legacy callers keep replaying; confirmed the combo path returns before the snapshot is built, which is why the old scopeMismatch guard on providerContinuation is no longer reachable.
  • Regressions cover a mismatched continuation with delta-only input, a mismatched request whose input looks complete, an explicit full replay without the ID succeeding afterwards, same-scope replay, both scoped/unscoped transitions, two different scopes, legacy unscoped replay, and zero upstream sends on every refusal.
  • git diff --check is clean; no file-size cap was raised and no test was deleted.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Summary by CodeRabbit

  • Bug Fixes

    • Continuation requests with mismatched task scopes now consistently return a generic previous_response_not_found error.
    • Replay state is no longer silently altered when scope validation fails.
    • Matching scopes and legacy unscoped continuations continue to replay normally.
    • Sensitive replay-state details remain hidden from client responses.
    • Callers can retry by resending the full conversation without previous_response_id.
  • Documentation

    • Clarified continuation ownership, scope mismatch behavior, and retry guidance.
  • Tests

    • Added regression coverage for continuation scope handling and retry behavior.

A task-scope mismatch correctly refused to replay another task's state, then
stripped previous_response_id and continued with whatever input the request
carried. When that input was only a delta, the proxy silently turned a
continuation into a fresh conversation and the model answered without the
history the client believed it had. Missing or corrupt state already asks the
client to replay in full; a mismatch now does the same.

The refusal is the existing generic previous_response_not_found response, byte
for byte, emitted before any upstream send. It is returned even when the input
looks complete, because this process cannot prove that it is; a caller that
really has the whole conversation retries without previous_response_id and
succeeds. The response reveals neither the stored scope nor whether foreign
state exists, and the internal reason stays internal.

Same-scope continuation is unchanged, and two absent scopes remain the legacy
unscoped cohort that still replays together. This is a deliberate change to the
historical fresh-start behavior, recorded in the transport contract and in the
client-facing guides.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 19, 2026 08:12
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-19T08:15:34.372364Z 2e4681f PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: db50eb71-ee4f-4b77-8a7f-0449aa929d85

📥 Commits

Reviewing files that changed from the base of the PR and between 110ea92 and f394368.

📒 Files selected for processing (1)
  • tests/server/plaintext-v2-agent-messages-server.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

Task-scope mismatches now create structured previous_response_not_found failures. The proxy preserves previous_response_id, blocks provider dispatch, retains valid same-scope and legacy unscoped replay, and documents the updated behavior.

Changes

Task-scoped continuation replay

Layer / File(s) Summary
Replay failure model
src/responses/state.ts, tests/responses/responses-state.test.ts
scope_mismatch is added to replay failure reasons. Scope mismatches preserve previous_response_id and record the failure on the original request. Tests cover normalized scopes, unscoped requests, and post-restart behavior.
Server replay enforcement
src/server/responses/core-combo.ts, src/server/responses/request-prepare.ts, tests/codex-integration/issue-702-expired-replay-state.test.ts, tests/server/server-combo-failover-e2e.test.ts, tests/server/plaintext-v2-agent-messages-server.test.ts
Server paths use the structured replay failure, log scope mismatches, return the generic 400 previous_response_not_found response, and avoid provider dispatch for invalid continuations. Tests cover HTTP, WebSocket, combo, full-history retry, valid scoped replay, legacy unscoped replay, and the unified error text.
Replay contract documentation
structure/transports/responses.md, docs-site/src/content/docs/guides/codex-integration.md, docs-site/src/content/docs/ko/guides/codex-integration.md
Documentation states that scope mismatches use the generic refusal, do not reveal stored state details, and require clients to retry without previous_response_id.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting Responses continuations when the task scope does not match.
Linked Issues check ✅ Passed Issue #5124 coding requirements are met. In src/responses/state.ts, PreviousResponseReplayFailure.reason includes scope_mismatch, and expandPreviousResponseInput preserves the original `previo…
Out of Scope Changes check ✅ Passed The changed files remain connected to Issue #5124. The state change implements fail-closed scope handling. The request and combo changes enforce the generic refusal before forwarding. The test changes…
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 76 / 80

이전에는 task scope가 안 맞으면 previous_response_id를 벗기고 그대로 새 대화처럼 이어갔습니다. 히스토리 재생은 막았지만, 클라이언트가 델타만 보냈을 때는 프록시가 조용히 “처음부터” 답을 내서, 클라이언트가 가진 맥락과 모델이 본 맥락이 어긋났습니다. 없어지거나 깨진 상태는 이미 previous_response_not_found로 전체 대화를 다시 보내라고 했는데, scope 불일치만 예외로 신선 시작을 허용했던 구멍입니다. #5124가 그걸 적었고, 이 PR은 그 구멍을 닫습니다.

바꾸는 곳은 세 갈래입니다. src/responses/state.ts에서 mismatch 때 withoutPreviousResponseId로 새 바디를 만들던 길을 없애고, replayFailures에 scope_mismatch를 심은 뒤 원본 body를 그대로 돌려줍니다. WeakSet/replayScopeMismatches도 같이 사라집니다. request-prepare.ts와 core-combo.ts는 한 번만 previousResponseReplayFailure를 읽고, mismatch면 기존과 같은 바이트의 generic 400을 upstream 전송 전에 냅니다. 경고 로그도 “fresh로 이어간다”가 아니라 “거부한다”로 바뀌었습니다. 응답에는 저장된 scope도, 외국 상태가 있는지도 안 나갑니다.

의도적으로 예전 “조용한 신선 시작”을 버립니다. 입력이 완전해 보여도 거부합니다. 프록시가 그 입력이 전체 대화인지 증명할 수 없기 때문입니다. 진짜로 전체를 가진 호출자는 previous_response_id 없이 다시 보내면 됩니다. 같은 scope 이어가기와, 양쪽 scope가 비어 있는 legacy unscoped 코호트는 그대로입니다. scoped↔unscoped 전환과 서로 다른 두 scope는 전부 mismatch입니다.

문서도 같이 맞춰 두었습니다. structure/transports/responses.md와 docs-site 영문·한글 Codex 통합 가이드에 “같은 거부 + 전체 재전송” 정책을 적었습니다. 테스트는 델타-only mismatch, 완전해 보이는 입력 mismatch, ID 없이 재시도 성공, same-scope, scoped/unscoped 양방향, 두 다른 scope, legacy unscoped, 거부 시 upstream 0회를 고정합니다. ratchet 파일 크기도 올리지 않았습니다. 로컬 스위트는 이 레인이 금지라 NOT RUN이고, 호스티드 CI가 증거입니다. 이 글을 쓰는 시점에는 hygiene/changes 등이 아직 pending입니다. types/config 대형 스플릿에 무효화될 PR이 아닙니다.

경로 src/responses/state.ts expandPreviousResponseInput - mismatch 시 body 유지 + scope_mismatch provenance. 외국 히스토리를 절대 안 붙인다
경로 src/server/responses/request-prepare.ts / core-combo.ts - 한 번의 replayFailure 읽기, 동일 formatErrorResponse 400. combo는 snapshot 만들기 전에 반환한다
경로 previousResponseScopeMismatch - 이제 replayFailure.reason === "scope_mismatch" 래퍼. 외부 API 이름은 유지된다
문서 en/ko guides + structure/transports/responses.md - 클라이언트 복구 경로가 코드와 같다
테스트 state / combo e2e / issue-702 - “완전해 보이는 입력도 거부”와 “upstream 0”이 계약의 핵심이다
동작 변경 - 예전 silent fresh-start에 의존하던 클라이언트가 있으면 400을 받게 된다. 문서에 복구법이 있다

메인테이너의 판단이 필요한 지점

  • 호스티드 CI(특히 responses-state·combo e2e·issue-702 관련)가 그린인지 확인한 뒤 merge할지
  • silent fresh-start에 기대던 외부 클라이언트가 아직 있는지(있다면 릴리즈 노트에 한 줄 더 강조할지). 가이드에는 이미 적혀 있다
  • Closes #5124로 이슈를 같이 닫을지(권장)

너의 추천
호스티드 CI가 그린이면 merge한다. fail-closed 재생 원칙과 맞고, 문서·회귀가 같이 움직였다. types/config 스플릿 때문에 닫을 PR이 아니다. merge 시 #5124도 같이 닫힌다.

이 댓글은 grok-bot이 작성했습니다

@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 19, 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: 2e4681f0d1

ℹ️ 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/responses/state.ts Resolved
…answered

The refusal was correct but distinguishable. A continuation this process has never
seen records no replay failure and reaches the route-dependent refusals, while a
mismatch answered earlier with a different message. That difference is itself the
disclosure the issue asks to avoid: a caller could tell "state exists and belongs
to another task" from "no state at all".

A mismatch now falls through to those same route-dependent refusals, so both
answers are byte-identical on any given route, and a destination that genuinely
owns the continuation chain keeps behaving as it does today for an unknown id
rather than being rejected for lacking local state. The regressions compare the
two envelopes on the same route and assert neither reaches the model.
The existing WebSocket coverage exercises expired and missing replay state. A
scope mismatch is the third way local replay becomes unusable, and the client
has to see the same frame or Codex terminates the task instead of reconnecting
with its full input. The case stores state under one task, reconnects as
another, asserts the generic refusal frame with no upstream request, and then
replays complete input without the id and succeeds.
@lidge-jun

Copy link
Copy Markdown
Owner Author

추가 리뷰 · 우선순위 78 / 80

이전 리뷰(76/80, tip 2e4681f0) 이후 tip이 58f540ef로 움직였습니다. 새 커밋 두 개입니다. (1) 92a544fa — scope mismatch 답을 “이 프로세스가 본 적 없는 id”와 바이트 단위로 같게 맞추기 (2) 58f540ef — 그 계약을 WebSocket 경로에도 고정.

이전 리뷰가 말한 “generic previous_response_not_found 400을 바로 낸다”는 방향은 맞았지만, 구현이 아직 구분 가능했습니다. request-prepare.ts / core-combo.ts가 scope_mismatch를 포함해 모든 replayFailure에서 같은 formatErrorResponse 메시지(“Continuation state is unavailable or corrupt…”)로 바로 끊었습니다. 그런데 진짜로 없는·만료된 id는 그 아래 라우트별 거부로 가서 다른 문장(“OpenAI forward continuation state is unavailable or expired…”, “Routed continuation requires unavailable local history…”)을 씁니다. 메시지가 다르면 호출자는 “외국 task 상태가 있다”와 “상태가 없다”를 구분할 수 있고, 그게 #5124가 막으려던 유출입니다.

이번 푸시는 mismatch일 때 그 조기 return을 건너뜁니다. 경고 로그는 그대로(내용·scope 없음) 찍고, replayFailure.reason !== "scope_mismatch"일 때만 조기 400을 냅니다. mismatch는 아래로 떨어져서, 그 라우트가 모르는 id에 주는 거부와 같은 봉투를 받습니다. combo도 같은 규칙입니다. state.ts의 scope_mismatch provenance·body 유지는 그대로입니다.

테스트가 그 약속을 잠급니다. issue-702는 mismatch와 expired를 같은 라우트에서 돌린 뒤 status·responseText가 같고 upstream도 둘 다 첫 턴만 있는지 비교합니다. 기대 메시지도 라우트별 문장으로 고쳤습니다. WebSocket은 task-a 상태 위에 task-b로 재접속해 같은 previous_response_not_found 프레임·upstream 0·ID 없이 전체 재전송 성공을 고정합니다. combo e2e 기대 문구도 routed 경로 문장으로 맞췄습니다.

경로 src/server/responses/request-prepare.ts - mismatch는 조기 400을 건너뛰고 라우트별 거부로 fall-through
경로 src/server/responses/core-combo.ts - 동일. 멤버마다 모르는 id와 같은 규칙
경로 tests/codex-integration/issue-702-expired-replay-state.test.ts - 바이트 동일성 + WS 복구 회귀
경로 tests/server/server-combo-failover-e2e.test.ts - routed 거부 문장과 맞춤
이전 tip 2e4681f0 → 58f540ef - 정책 보완이지 방향 철회가 아님

메인테이너의 판단이 필요한 지점

  • 새 tip 기준 호스티드 CI(특히 issue-702·combo e2e)가 그린인지 확인한 뒤 merge할지. 이 글을 쓰는 시점에는 hygiene/label은 pass, changes 등은 아직 pending
  • 문서(guides / structure/transports/responses.md)가 “같은 거부”를 라우트별 동일성까지 분명히 말하는지. 코드는 이미 그렇게 동작한다

너의 추천
호스티드 CI가 그린이면 merge한다. 이전 추천을 유지하되, 유출 구멍을 실제로 막은 보완이라 점수는 78로 올린다. types/config 스플릿과 무관. Closes #5124 유지.

이 댓글은 grok-bot이 작성했습니다

…forwarding

Letting a scope mismatch fall through to the route-dependent handling was wrong.
An id this process cannot resolve is the destination's business, and a
destination that owns the continuation chain is entitled to receive it. An id
this process CAN resolve, to another task's retained state, is not the same
thing: forwarding it continues that task's conversation for a different caller.

The refusal now happens before that exception, on both the ordinary and combo
paths, and uses the one generic local-unavailable envelope. The paired
regression asserts a resolved foreign id is refused with no upstream request
while an unknown id on the identical native route is still forwarded.
@lidge-jun

Copy link
Copy Markdown
Owner Author

추가 리뷰 · 우선순위 78 / 80

이전 추가 리뷰(78/80, tip 58f540ef) 이후 tip이 d068d88c로 움직였습니다. 새 커밋은 하나입니다. fix(responses): refuse a resolved foreign continuation before native forwarding. base는 그대로 dev입니다.

바로 전 커밋(92a544fa)은 mismatch를 “이 프로세스가 본 적 없는 id”와 같은 길로 떨어뜨렸습니다. 답이 같아야 외국 상태가 있다는 사실이 안 샌다고 본 겁니다. 그 선택은 네이티브 패스스루에서 틀렸습니다. 없는 id는 목적지가 체인을 들고 있으면 그대로 넘겨야 합니다. mismatch는 없는 id가 아닙니다. 이 프로세스가 다른 task 상태로 이미 풀어 본 id입니다. 그걸 같은 길로 보내면 다른 호출자가 그 대화를 이어가게 됩니다.

이번 푸시는 그 fall-through를 접습니다. request-prepare.ts와 core-combo.ts는 replayFailure가 있으면 이유와 상관없이, 라우트별 처리보다 먼저 generic previous_response_not_found 400을 냅니다. mismatch 경고 로그는 그대로입니다. 내용도 scope도 안 넣습니다. combo는 멤버에게 주기 전에 막으므로, 멤버가 네이티브여도 외국 id는 안 나갑니다.

테스트가 그 경계를 잠급니다. “mismatch와 만료가 바이트까지 같다”는 비교는 빠졌습니다. 그 자리에 쌍 회귀가 들어갔습니다. 같은 네이티브 라우트에서, 풀린 외국 id는 400이고 upstream은 0회입니다. 한 번도 본 적 없는 id는 200이고 그 id가 그대로 올라갑니다. HTTP mismatch 기대 문구와 combo e2e도 generic “Continuation state is unavailable or corrupt…”로 다시 맞췄습니다. WebSocket 거절과 전체 재전송 복구는 그대로입니다.

Codex P2는 아직도 “없는 id와 외국 id를 겉으로 같게 하라”고 합니다. 이번 커밋은 그걸 고의로 버린 겁니다. 네이티브에서 400과 200은 구분이 됩니다. 대신 외국 대화를 넘기지는 않습니다. 둘 다 지킬 수는 없습니다.

경로 src/server/responses/request-prepare.ts - replayFailure면 네이티브 전달 예외보다 먼저 generic 400
경로 src/server/responses/core-combo.ts - 동일. 멤버 디스패치 전
경로 tests/codex-integration/issue-702-expired-replay-state.test.ts - 외국 id 거부 vs 미지 id 전달을 한 라우트에서 고정
경로 tests/server/server-combo-failover-e2e.test.ts - generic 거부로 복귀
이전 tip 58f540ef → d068d88c - fall-through 철회. 보안 방향은 이 커밋이 맞음

메인테이너의 판단이 필요한 지점

  • 새 tip 기준 호스티드 CI(특히 issue-702·combo e2e)가 그린인지 확인한 뒤 merge할지. 이 글을 쓰는 시점에는 changes/hygiene/test 2/4는 pass, 나머지 test·macos·gates는 pending
  • [Bug]: Request full replay when a task-scoped continuation cannot be used #5124 수락 조건의 “존재 여부도 드러내지 않는다”와, 네이티브에서 미지 id는 전달해야 한다는 제품 규칙이 충돌한다. 이번 PR은 전달 금지를 골랐다. 문서의 “존재 여부를 노출하지 않는다”는 네이티브 400 vs 200까지는 덮지 못한다. 그 한 줄을 고칠지는 판단이 필요하다
  • Codex P2는 이번 커밋으로 닫힌 이슈가 아니라, 의도적으로 버린 대안이다. 무시해도 된다

너의 추천
호스티드 CI가 그린이면 merge한다. 이전 추가 리뷰가 맞다고 본 fall-through는 접는 게 맞다. types/config 스플릿과 무관. Closes #5124 유지.

이 댓글은 grok-bot이 작성했습니다

@lidge-jun

Copy link
Copy Markdown
Owner Author

추가 리뷰 · 우선순위 77 / 80

이전 추가 리뷰(78/80, tip 58f540ef) 이후 tip이 d068d88c로 움직였습니다. 커밋은 하나, d068d88입니다. 제목은 “이미 해석된 외국 continuation을 native forwarding 전에 거부한다”입니다. 바로 앞 푸시가 한 선택을 되돌립니다.

앞 푸시(92a544fa)는 mismatch를 조기 400에서 빼 두었습니다. 이 프로세스가 본 적 없는 id와 같은 라우트 거부를 받게 해서, 메시지만 보고 “외국 상태가 있다”를 알아채지 못하게 하려는 선택이었습니다. 이번 커밋은 native 경로에서 그 선택이 틀렸다고 말합니다. 모르는 id는 목적지가 이어갈 권리가 있습니다. 그런데 이 프로세스가 다른 task 상태로 이미 풀어 둔 id를 그대로 내려보내면, 목적지는 그 대화를 이 호출자 앞으로 이어갑니다. 그래서 mismatch는 그 예외 앞에서 다시 끊습니다. ordinary와 combo 모두 replayFailure가 있으면 바로 generic previous_response_not_found 400입니다. 문구는 “Continuation state is unavailable or corrupt…”. 경고 로그는 그대로이고, 내용과 scope는 안 나갑니다.

테스트가 그 짝을 잠급니다. 같은 native 라우트에서 외국 id는 400이고 upstream은 0입니다. 한 번도 본 적 없는 id는 200으로 전달됩니다. 앞에서 넣었던 “mismatch와 expired의 responseText가 바이트로 같다” 테스트는 지웠습니다. issue-702 HTTP mismatch와 combo e2e 기대 문구도 다시 generic corrupt 문장입니다. WebSocket으로 거부된 뒤 전체 재전송으로 복구하는 케이스는 남아 있습니다.

경로 src/server/responses/request-prepare.ts / core-combo.ts - 모든 replayFailure를 다시 조기 거부. native에서 외국 대화를 넘기지 않는 쪽은 맞다
경로 tests/codex-integration/issue-702-expired-replay-state.test.ts - 외국 vs 미지 id 짝 회귀가 이번 계약의 핵심이다. indistinguishability 테스트 삭제는 의도된 후퇴다
문서 structure/transports/responses.md · guides - “외국 상태가 있는지를 응답이 안 드러낸다”는 문장은 이제 코드와 안 맞다. native는 400 vs 200으로 드러난다. ChatGPT forward와 routed는 mismatch가 generic corrupt이고, 미지·만료 id는 라우트별 다른 문장이다
이전 tip 58f540ef → d068d88c - 문장 유출보다 대화 가로채기가 더 무거운 버그라서 방향을 바꿨다

메인테이너의 판단이 필요한 지점

  • native에서 미지 id 전달과 외국 id 거부의 구분(400 vs 200)을 허용할지. 이번 커밋은 허용하고 테스트로 고정했다. 대화를 넘기는 쪽이 더 나쁘다
  • routed/forward에서는 둘 다 어차피 400이다. 다른 건 문장뿐이다. 여기까지 맞출지, 아니면 지금처럼 두고 문서만 고칠지
  • 호스티드 CI. 이 글을 쓰는 시점에 hygiene/gates/changes/test 2·4는 pass, test 1/4·3/4와 macos는 아직 pending

너의 추천
native hijack 수정은 맞고, 호스티드 CI가 그린이면 merge한다. 점수는 77. 앞 리뷰(78)보다 한 칸 내린 이유는 라우트별 문장 유출이 다시 열렸고, 문서가 아직 예전 문장이기 때문이다. 머지 전에 문서 한 줄을 “native는 미지 id를 전달하고, 해석된 외국 id만 거절한다”로 고치는 게 좋다. types/config 스플릿과 무관하다. Closes #5124는 유지한다.

이 댓글은 grok-bot이 작성했습니다

A resolved foreign continuation is still refused unconditionally, before any
route-dependent handling, so another task's conversation is never forwarded. What
changed is the two remaining local-unavailable answers: the canonical forward and
routed-continuation refusals carried their own message text, so which local
failure had occurred was readable from the response even though all three mean
the same thing to a client. They now use the message the early failures already
use.

Genuine upstream-owned continuation is untouched: an id with no local state at
all still reaches a destination that owns the chain. The added case compares
missing and mismatched state on one route and asserts identical status, type,
code and message.
…lity check

The equality case compared a mismatch against expired state and inferred the
corrupt answer from the source string. It now produces each condition for real on
one route - a spill deleted, a spill overwritten with bytes that do not parse, and
an entry retained under a different task - and asserts byte-identical responses
with no upstream request.
Unifying the local-unavailable answers changed the canonical-forward text this
assertion matched on. It now matches the single message every local replay
failure uses.
A second assertion matched the retired canonical-forward text through
/continuation state.*expired/i, which is why unifying the message turned shard
four red as well. The expected envelope now lives in one constant that every
local-replay assertion in this file uses, so a future change to that contract
fails one place rather than several written from memory.

Status, error code, upstream-send counts and retention assertions are all
unchanged; nothing was relaxed to a truthy check.
@lidge-jun

Copy link
Copy Markdown
Owner Author

Maintainer integration into dev under MAINTAINERS.md, without claiming self-approval.

Reviewed head 4209818e998d716273fe44382278a0f5f002a739 passed independent source and security-boundary review. Explicit scope mismatch refuses delta-only continuation before upstream I/O; legitimate same-scope and unknown native continuation remain distinct. Missing, corrupt and mismatched local state use the same public response. HTTP, combo and real WebSocket mismatch/recovery coverage remains intact. The final fixture-only interdiff preserves status, code, send-count, retention and recovery assertions.

Exact-head hosted run35435034056 succeeded with all applicable producers and aggregate CI. The cancelled control run was rerun separately as 35435033238 attempt2 and succeeded. Current review threads are resolved and no maintainer objection remains. Local suites/tests/typecheck/build were NOT RUN.

Static integration against dev bb2fa5ab25d65a4e5fbab9253f5cc750cbbf2dcf is clean, including the now-landed decoder and transport changes. Native membership is absent. The merge is pinned to the reviewed head; #5124 closes only after dev acceptance is verified.

@lidge-jun
lidge-jun merged commit acd43bf into dev Sep 19, 2026
30 of 31 checks passed
@lidge-jun
lidge-jun deleted the codex/continuation-scope-replay branch September 19, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant