Skip to content

fix(responses): drop account-bound continuation when the serving account changes (#4546) - #4641

Merged
lidge-jun merged 4 commits into
devfrom
codex/4546-wpi-account-change-state-scrub
Sep 14, 2026
Merged

lidge-jun merged 4 commits into
devfrom
codex/4546-wpi-account-change-state-scrub

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

When Codex pool routing serves a live conversation on a different account, the next turn now drops the account-bound continuation state once, before dispatch, instead of replaying it at a credential that cannot read it.

OpenAI returns reasoning as encrypted_content blobs that only the issuing account can decrypt, and previous_response_id names server-side state owned by that account. So a move between pool accounts replayed account A's ciphertext and continuation id to account B, the turn failed, and the conversation could not recover — switching the account in the dashboard changed nothing, because the carried state was still A's. That is the shape an operator experiences as a session that stays stuck after an account change.

src/server/responses/account-change-state.ts holds the decision in one place. canPortConversationState refuses a move for a request carrying previous_response_id, a provider-side conversation id, uploaded file ids, or encrypted reasoning, each with a typed reason; src/routing/identity-domains.ts owns that contract once it reaches this line, and the function is deliberately a single swap point rather than a rule scattered across call sites. The issuer association lives beside thread affinity in src/codex/routing.ts, keyed by the same affinity key, bounded by the same idle TTL and entry cap, and process-local — no raw account id reaches a log or disk.

The scrub uses the existing helpers rather than a second implementation: sanitizeReasoningInputContent with stripEncryptedContent, plus stripAgentMessageCiphertextInPlace. Readable history survives, which is the whole point — the conversation continues on the new account from plaintext rather than being lost. This is one cold turn at the moment of the change, not a permanent downgrade: the next successful serve records the new issuer and carried state is used normally again.

Both paths are wired. The ordinary Responses path scrubs after the serving account is known and before the adapter builds the request, and the in-request alternate-account retry scrubs against the account the body was originally prepared for. src/server/responses/compact.ts applies the same rule at its four dispatch points, including the routed fallback. A scrub is recorded on the request log as conversationStateScrub: "account-change" and warned once, in the style of the existing mismatched-task-scope warning, so a cold turn after a move reads as deliberate rather than as a bug.

Stacked on #4640.

Verification

Not run, by explicit instruction: the local suite, bun run typecheck, bun install, and any build. The only proof for this unit is hosted CI at the exact final head SHA; this push used --no-verify.

New coverage in tests/responses/account-change-state-scrub.test.ts: a turn served by the same account keeps previous_response_id and its encrypted reasoning untouched; a serving-account change drops both while the readable user message survives; the compact routed-fallback body obeys the same rule; an in-request alternate-account retry scrubs even before an issuer has been recorded; and canPortConversationState refuses each carrier with its own reason. Registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json, with the contract recorded in structure/transports/responses.md.

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

  • New Features

    • Improved conversation continuity when requests are served by different pool accounts.
    • Automatically removes account-bound continuation data when it cannot safely transfer between accounts.
    • Applies this behavior to standard and compact responses, including account-retry scenarios.
  • Bug Fixes

    • Preserves readable request content and portable state during account changes.
    • Adds request and usage logging for account-change state scrubbing.
  • Documentation

    • Documented conversation-state portability and scrubbing behavior.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 14, 2026 15:24
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: b6c9de38-3bd2-46c1-bc09-19d06f3c6b80

📥 Commits

Reviewing files that changed from the base of the PR and between 38a2d9f and 4136660.

📒 Files selected for processing (11)
  • scripts/test-layout/layout.json
  • src/codex/routing.ts
  • src/server/request-log.ts
  • src/server/responses/account-change-state.ts
  • src/server/responses/compact.ts
  • src/server/responses/core.ts
  • src/usage/log.ts
  • structure/manifest.json
  • structure/transports/responses.md
  • tests/fixtures/test-layout-expected.json
  • tests/responses/responses-account-change-scrub.test.ts

📝 Walkthrough

Walkthrough

The change tracks which pool account issued conversation state, removes non-portable continuation state after account changes, records scrub metadata, and integrates the behavior into Responses and compact flows. Tests cover preservation, scrubbing, retries, logging, and carrier detection.

Changes

Conversation-state portability

Layer / File(s) Summary
Issuer tracking and portability policy
src/codex/routing.ts, src/server/responses/account-change-state.ts
The routing layer stores bounded state issuers. The portability module detects account-bound carriers, evaluates portability, and scrubs unportable continuation fields.
Response pipeline integration and scrub telemetry
src/server/responses/core.ts, src/server/responses/compact.ts, src/server/request-log.ts, src/usage/log.ts
Responses and compact flows scrub state for changed accounts and alternate-account retries. Successful serving accounts become issuers. The "account-change" marker propagates through request and usage logs.
Validation, test layout, and documentation
tests/responses/responses-account-change-scrub.test.ts, tests/fixtures/test-layout-expected.json, scripts/test-layout/layout.json, structure/transports/responses.md, structure/manifest.json
Tests cover same-account preservation, account changes, compact requests, retries, logging, and carrier detection. Documentation and layout fixtures register the behavior.

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

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResponsesPipeline
  participant StateScrubber
  participant CodexPool
  participant RequestLog
  Client->>ResponsesPipeline: Submit response or compact request
  ResponsesPipeline->>StateScrubber: Provide serving account and conversation state
  StateScrubber->>StateScrubber: Compare issuer and serving account
  StateScrubber->>ResponsesPipeline: Return scrubbed request when state is not portable
  ResponsesPipeline->>CodexPool: Dispatch request
  ResponsesPipeline->>RequestLog: Record account-change scrub marker
Loading
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/4546-wpi-account-change-state-scrub

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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 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-14T15:29:42.292641Z eab3996 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.

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

ℹ️ 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/codex/routing.ts
export function clearThreadAccountMap(): void {
threadAccountMap.clear();
threadAffinityEntryTotal = 0;
conversationStateIssuerMap.clear();

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 issuer state across manual account selection

When the dashboard changes the active account, resetCodexRoutingForManualSelection() calls this function, which now erases the issuer record along with affinity. The next request therefore finds no issuer and has no priorAccountId, so applyAccountChangeConversationStateScrub() considers the account unchanged and forwards account A's previous_response_id or ciphertext to account B unchanged—the primary manual-switch scenario remains stuck. Keep issuer records when resetting affinity for a manual selection, while retaining a separate full reset for tests or shutdown.

Useful? React with 👍 / 👎.

Comment on lines +169 to +173
if (partRecord.type === "encrypted_content" && typeof partRecord.encrypted_content === "string") {
parts[index] = { type: "input_text", text: OMITTED_ENCRYPTED_CONTENT_TEXT };
stripped += 1;
}
}

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 Strip nested uploaded-file references during account changes

When carried history contains the normal nested file shape such as message.content[].file_id or output[].file_id, collectFileIds() classifies it as unportable, but this loop only replaces encrypted_content; the later file_id deletion applies to the outer input item, not these nested parts. The old account's file ID consequently remains on the request sent to the new account and can still be rejected. Remove or replace nested account-bound file references while preserving any independently portable inline content.

Useful? React with 👍 / 👎.

@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from 50dba9c to 7f7c4fc Compare September 14, 2026 15:32
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch from eab3996 to d40c3e4 Compare September 14, 2026 15:32
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 70 / 80

이 PR은 Codex 풀에서 계정(크레덴셜)이 바뀌었을 때, 이전 계정이 만든 이어가기 상태를 그대로 다음 계정에 보내지 않게 막는 수정입니다. OpenAI 쪽 previous_response_idencrypted_content(암호화된 추론 덩어리)는 그 상태를 만든 계정만 읽을 수 있습니다. 풀 라우팅이 계정 A에서 B로 옮긴 뒤에도 A의 암호문·이어가기 id를 그대로 보내면, 대시보드에서 계정을 아무리 바꿔도 세션이 계속 막히는 느낌이 납니다. 이 PR은 그 모양을 한 번 끊고, 사람이 읽을 수 있는 대화 텍스트만 남긴 채 새 계정으로 이어서 가게 합니다.

지금 dev(HEAD 627274b8f)에는 이미 #4624(wpc)로 src/routing/identity-domains.tscanPortConversationState가 올라와 있고, 스택 앞단 wpc→wpe→wpf도 랜딩된 상태입니다. 이 브랜치(codex/4546-wpi-account-change-state-scrub)에도 같은 identity-domains.ts가 있습니다. 그런데 실제 스크럽 결정은 src/server/responses/account-change-state.ts같은 이름의 로컬 복사본으로 다시 두었고, 주석에는 「identity-domains가 이 라인에 오면 바꾼다」고 적혀 있습니다. 발행자(issuer) 기억은 src/codex/routing.ts의 process-local 맵이고, 스레드 어피니티와 같은 키·같은 유휴 TTL·같은 엔트리 상한을 씁니다. /v1/responses/v1/responses/compact(라우트 폴백 포함), 그리고 같은 요청 안의 대체 계정 재시도 경로에 스크럽을 걸었고, 요청 로그에는 계정 id 없이 conversationStateScrub: "account-change"만 남깁니다. 새 단위 테스트 tests/responses/account-change-state-scrub.test.tsstructure/transports/responses.md 기록도 같이 왔습니다.

base는 codex/4546-wpd-v2-lineage-placement(#4640)라서, dev에 바로 머지할 수 있는 단독 PR이 아니라 #4546 스택의 배선 레이어(wpi) 입니다. 090 스택 표의 7층(wpc…wpg) 바깥에 붙은 「계정 변경 시 대화 상태 스크럽」이고, wpc가 이미 올려 둔 이식성 판정을 호출 경로에 실제로 연결하는 자리입니다. 로컬 스위트·typecheck는 돌리지 않았고 hosted CI가 증거인 점, --no-verify 푸시는 스택 공통 자세와 같습니다.

src/server/responses/account-change-state.ts canPortConversationState - 이미 같은 브랜치에 있는 src/routing/identity-domains.ts export와 내용이 같은 복사본이다. 주석의 「아직 안 왔다」는 dev·이 스택 기준으론 틀렸다. 여기서 import/재export로 한곳만 쓰게 바꿔야 나중에 규칙이 갈라지지 않는다.

applyAccountChangeConversationStateScrub (issuer null + priorAccountId 없음) - 프로세스 재시작이나 첫 턴처럼 issuer 맵이 비어 있으면 accountChanged가 false가 되어, 클라이언트가 들고 온 previous_response_id/암호문이 있어도 스크럽이 안 돈다. 「발행자를 모른다」와 「계정이 안 바뀌었다」를 같은 뜻으로 취급하는 구멍이다.

src/codex/routing.ts clearThreadAccountMapForAccount - 계정을 unusable로 지울 때 issuer 맵 항목은 안 지운다. 전체 clear에서만 같이 비운다. 보통은 다음 계정으로 가서 스크럽이 나서 괜찮지만, 계정 제거/재등록 뒤 같은 accountId가 다시 살아나는 경로에서는 오래된 issuer가 남을 수 있다.

stripEncryptedContentPartsInPlace - 이름과 달리 file_id/file_ids도 지운다. 동작은 이식성 거부와 맞지만, 나중에 「암호문만 지운다」고 읽으면 실수하기 쉽다. 파일 id 제거를 분리하거나 이름을 맞추는 편이 낫다.

peekConversationStateIssuer - peek만 해도 lastUsedAt을 갱신해 TTL을 늘린다. 어피니티와 같은 패턴이긴 한데, 「읽기만 했는데 수명이 늘어남」이 의도인지 한 줄로 박아 두면 좋다.

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

  • 로컬 canPortConversationState를 지금 당장 identity-domains import로 바꿀지, 스택 머지 직후 정리 PR로 미룰지
  • issuer 맵이 비어 있을 때(재시작·콜드 스타트)에도 캐리어가 있으면 보수적으로 스크럽할지, 아니면 「모름 = 스크럽 안 함」을 유지할지
  • wpi를 090 표에 공식 층으로 넣을지, wpd 위의 부가 배선으로만 둘지

너의 추천
CI 그린을 본 뒤, canPortConversationStateidentity-domains에서 import하도록 고치고(복제 삭제), issuer-unknown + 캐리어 존재 시 스크럽 여부를 테스트 한 줄로 고정한 다음 #4640 위에 머지한다. dev 직행은 base가 스택이라 하지 말고, 앞 레이어(#4640 등) 순서대로 랜딩한다.

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

@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from 7f7c4fc to b85b0bd Compare September 14, 2026 15:49
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch from d40c3e4 to 97bcf19 Compare September 14, 2026 15:49
@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from b85b0bd to bd2a4d4 Compare September 14, 2026 16:08
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch from 97bcf19 to def83bf Compare September 14, 2026 16:08
@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from bd2a4d4 to 3711e49 Compare September 14, 2026 16:14
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch 2 times, most recently from adc06f0 to 7082981 Compare September 14, 2026 16:22
@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from b467d4f to f1a140c Compare September 14, 2026 16:30
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch from 7082981 to d23fe24 Compare September 14, 2026 16:30
@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from f1a140c to f4e541e Compare September 14, 2026 16:30
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch 3 times, most recently from c27e27e to 4f7210a Compare September 14, 2026 16:37
@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from 975d177 to 635f631 Compare September 14, 2026 16:48
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch 3 times, most recently from 1fad689 to f92d0c0 Compare September 14, 2026 17:10
@lidge-jun
lidge-jun force-pushed the codex/4546-wpd-v2-lineage-placement branch from 38df147 to cc70912 Compare September 14, 2026 17:47
Base automatically changed from codex/4546-wpd-v2-lineage-placement to dev September 14, 2026 17:47
…unt changes (#4546)

OpenAI encrypted_content blobs and previous_response_id are readable only by the account that minted them, so a pool move replayed account A's ciphertext to account B and the conversation could not recover no matter how many times the account was switched.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof. Pushed with --no-verify.
structure/transports/responses.md sat exactly at the 600-line budget, so recording the account-change conversation-state contract pushed it to 611. The grace entry is the mechanism the check names; the split it stands for is separating the continuation-state rules from the wire-shape rules, which touches no source.
…ontinuation id (#4546)

Hosted CI failed the #2247 row that already proves reasoning and compaction ciphertext are stripped when a pooled thread moves accounts, and in a specific shape: the reasoning item keeps its readable summary with an emptied content array, and the compaction item becomes an operator-readable note. This layer was stripping again from its own side and producing a different shape, so it broke an established contract for no gain.

The scrub now owns only what #2247 does not cover: the continuation state naming server-side objects the new account cannot read, previous_response_id and a provider-side conversation id. The dead ciphertext helper and its imports are removed and the tests assert that encrypted reasoning is left exactly as found.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof.
…in seed (#4546)

The membership oracle resolves an unmapped file through the regex seeds and fails when a seed disagrees with the explicit table. account-change-state-scrub.test.ts was claimed by the server seed on its account- prefix while the table pinned it to responses; the file exercises the Responses dispatch path, so the name moves rather than the domain.

Verification posture: local suite, typecheck, install and build NOT run by explicit instruction. Hosted CI at the exact final head is the only proof.
@lidge-jun
lidge-jun force-pushed the codex/4546-wpi-account-change-state-scrub branch from d316511 to 4136660 Compare September 14, 2026 17:47
@lidge-jun
lidge-jun merged commit 2b43c14 into dev Sep 14, 2026
3 of 18 checks passed
@lidge-jun
lidge-jun deleted the codex/4546-wpi-account-change-state-scrub branch September 14, 2026 17:47
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