Skip to content

fix(claude-sdk-oauth): persist the continuity binding across restarts - #809

Draft
1vivy wants to merge 2 commits into
code-yeongyu:mainfrom
1vivy:fix/claude-sdk-oauth-persist-continuity-binding
Draft

fix(claude-sdk-oauth): persist the continuity binding across restarts#809
1vivy wants to merge 2 commits into
code-yeongyu:mainfrom
1vivy:fix/claude-sdk-oauth-persist-continuity-binding

Conversation

@1vivy

@1vivy 1vivy commented Aug 11, 2026

Copy link
Copy Markdown

Fixes #808.

What this fixes

session-binding.ts has been in the tree since db8e8cfeb ("persist continuity bindings for
verified restart resume"), and AGENTS.md documents it as "Branch-local binding checkpoint for
restart-time resume verification". It was never wired. Nothing called latestBindingOnBranch(),
nothing called verifyBindingAgainstTranscript(), and nothing ever wrote a
claude-sdk-oauth-binding entry — that commit added the module and its unit test, 2 files, no call
sites, and the unit test stayed its only consumer.

So the only binding store that runs is the process-memory Map in session-reattach.ts. After a
restart both getSession() and getBinding() miss, decideNativeContinuity returns bootstrap,
and the lane flattens. session-stream.ts synthesizes the reason as registry_miss because
bootstrap carries none, and session-observability.ts reports the kind as flatten once
firstTurn is false. On a 697-message session that is 68.0KB and ~60K tokens re-sent on the first
turn after every restart.

Approach

  • Writesession-registry-wiring.ts appends a BindingCheckpoint through pi.appendEntry at
    the message_end commit boundary, only after the boundary reports a non-rewritten commit.
    registerSessionRegistry now takes Pick<ExtensionAPI, "on" | "appendEntry">.
  • Read — a new session_start handler lifts the newest checkpoint off
    ctx.sessionManager.getBranch() into a module map. The decision needs the current sent-hash
    prefix, which only exists once the provider context is built, so the checkpoint is held until
    createResidentAttempt can verify it.
  • VerifyrehydrateBindingFromCheckpoint rebuilds the binding from the current hashes and
    only after prefixDigest(hashes, sentCount) equals the recorded sentPrefixHash, and only when
    account, model, systemPromptHash and toolsetHash all match. Every refusal falls through to
    today's cold path, so the worst case is the behavior you already have.

Resume-first is preserved: this adds a way to reach reattach after a restart, and changes no
existing decision.

Design notes

  • The checkpoint stays compact on purpose. sentPrefixHash is one sha256 over the prefix; storing
    the full hash array would add tens of KB to the transcript on every turn.
  • A live entry gets its config-drift check from identityDrift, which a restarted process cannot
    run because the entry is gone. BindingCheckpoint therefore gained optional systemPromptHash
    and toolsetHash; absent means unknown, and unknown never rehydrates.
  • claudeConfigDir became optional because nothing consumes it.
  • verifyBindingAgainstTranscript is deliberately left untouched and still unused. Supplying
    its transcriptExists / transcriptHasBoundaryUuid honestly would mean hard-coding Claude Code's
    private projects/<mangled-cwd>/<id>.jsonl layout, and this extension deliberately treats resume
    failure as that gate instead (session-stream.ts catches it into
    resume_initialization_failed). I did not want to pass it invented trues. Happy to wire it with
    a real probe if you'd prefer that direction — flagging it as the one open design call.

Verification

  • test/suite/regressions/808-claude-sdk-oauth-binding-persistence.test.ts — 11 tests: checkpoint
    derivation, branch round-trip, successful rehydrate, six refusal cases (rewritten prefix,
    truncated history, changed system prompt, changed toolset, different account, different model),
    a pre-identity checkpoint, and single-use consumption. 11 passed.
  • Red-run proof: deleting the prefixDigest guard fails exactly
    refuses to rehydrate on a rewritten prefix (1 failed | 10 passed); restoring it returns 11/11.
  • npm run check passes (also enforced by the pre-commit hook on this commit).
  • test/claude-sdk-oauth-binding.test.ts, test/claude-sdk-oauth-continuity.test.ts and all of
    test/suite/regressions/ — 325 passed, 7 failed. Those 7 (theme init, fswatch, models.json hot
    reload, two model-selector cases, cross-project resume) reproduce identically with these source
    changes reverted, so they are pre-existing on main and unrelated.

Draft because of the open design call above and because I have not run the senpi-qa CLI channel —
the restart path needs a real multi-turn OAuth session, which I can do if you want that evidence
before review.


Summary by cubic

Persist continuity bindings for claude-sdk-oauth across restarts so sessions reattach instead of re-sending full history on the first turn. Fixes #808.

  • Bug Fixes
    • Write a compact binding checkpoint at message_end, read it at session_start, and rehydrate only if the current prefix digest and config identity (account, model, systemPromptHash, toolsetHash) match.
    • Fall back to the existing cold path when the prefix is rewritten/truncated or identity changes; checkpoints are single-use.
    • Updated registry wiring to append checkpoints and export prefixDigest; made claudeConfigDir optional.
    • Added regression tests covering successful rehydration and refusal cases.

Written for commit 0bdff32. Summary will update on new commits.

Review in cubic

1vivy added 2 commits August 11, 2026 00:13
session-binding.ts has existed since db8e8cf but was never wired: nothing
called latestBindingOnBranch() and nothing wrote a claude-sdk-oauth-binding
entry, so its unit test was the only consumer. The sole surviving binding store
was the process-memory Map in session-reattach.ts.

After a restart both getSession() and getBinding() miss, decideNativeContinuity
returns bootstrap, and the lane flattens. session-stream.ts synthesizes the
reason registry_miss because bootstrap carries none, and session-observability
reports it as flatten once firstTurn is false. A 697-message session re-sent
68.0KB on the first turn after every restart.

Write the checkpoint at the message_end commit boundary, lift it off the branch
at session_start, and rebuild the binding in createResidentAttempt only once the
current prefix digest matches the recorded one and account, model,
systemPromptHash and toolsetHash all agree. Every unproven case falls through to
the existing cold path.

fixes code-yeongyu#808
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude-sdk-oauth: the continuity binding is never persisted, so every restart re-sends the full history (registry_miss)

1 participant