Skip to content

fix(claude-sdk-oauth): preserve restart continuity after compaction - #1267

Merged
code-yeongyu merged 12 commits into
code-yeongyu:mainfrom
eddieparc:fix/claude-session-persistence
Sep 3, 2026
Merged

fix(claude-sdk-oauth): preserve restart continuity after compaction#1267
code-yeongyu merged 12 commits into
code-yeongyu:mainfrom
eddieparc:fix/claude-session-persistence

Conversation

@eddieparc

@eddieparc eddieparc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix Claude SDK OAuth restart continuity after compaction and preserve provider-owned OAuth account pools.

This replaces the persistence/root-cause portion of #1262 with a clean, reviewable history. User recovery commands are intentionally split into a separate PR.

Root cause

After compaction, the raw branch can carry empty materialized message bodies while SessionManager.buildSessionContext() owns the real active context. Restart-sidecar persistence hashed the raw branch, so it did not refresh the SDK lineage. A later process flattened and re-sent the full conversation, which could exceed the SDK request limit; retry fallback then repeated the same oversized payload.

Two adjacent continuity cases also invalidated a usable binding:

  • goal-continuation metadata appended after the committed assistant is not provider-sent divergence;
  • the resident registry entry can close before message_end, while the verified current binding still exists.

The OAuth account pool had a separate persistence defect: provider-owned pool results were flattened into generated slots and overlapping login flows could lose provider-assigned accounts.

Changes

  • derive sidecar sent hashes from the compaction-aware buildSessionContext();
  • persist from the verified current binding when the resident entry has already closed;
  • allow known unsent goal-continuation suffixes while still rejecting a later assistant rewrite;
  • preserve provider-owned account pools and merge concurrent login additions;
  • keep deterministic hard provider errors terminal at the original cause.

Failing-first proof

Mutation checks against the new tests:

  • removing compaction-aware context hashing: restart suite fails 2 tests;
  • removing current-binding fallback: restart suite fails 1 test;
  • rejecting the goal-continuation suffix: binding-anchor suite fails 1 test;
  • disabling provider-owned pool detection: pool suite fails 2 tests.

Verification

  • packages/ai: 2 focused files, 7 tests passed;
  • packages/coding-agent: 5 focused files, 40 tests passed;
  • bun run check: passed;
  • senpi-qa common harness: 10/10;
  • Anthropic mock-loop real CLI channel: 22/22, localhost-only, real auth unchanged;
  • CLI smoke: 8/8.

QA evidence is stored locally under local-ignore/qa-evidence/20260902-claude-session-persistence/.


Summary by cubic

Fixes Claude SDK OAuth restart continuity after compaction. It now persists the provider-visible, compaction-aware context instead of hashing the raw branch, so restarts reattach to the SDK session instead of flattening and resending an oversized transcript.

  • Falls back to a verified current binding when the resident entry closes before message_end, requiring matching message counts and prefix digests.
  • Validates the restart record before appending its marker, so a mismatched fallback can no longer leave a marker-only entry that retires the still-valid older sidecar.
  • Preserves valid count-zero anchors and goal-continuation and goal-cache-warmup metadata while rejecting later conversation rewrites.

Written for commit dba53d1. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai 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.

3 issues found across 15 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/ai/src/auth/pool/slots.ts">

<violation number="1" location="packages/ai/src/auth/pool/slots.ts:159">
P1: When two provider-owned logins start with no stored accounts, both results use `default`, so this filter drops the first login's distinct account and the later `return flat` overwrites it. Reconcile same-name slots by material or assign a unique name after reading the serialized latest state.</violation>

<violation number="2" location="packages/ai/src/auth/pool/slots.ts:161">
P2: When an account is pinned or unpinned during the OAuth flow, this merge copies the login's stale `pinned` value over the latest stored value. Preserve current pool metadata while merging the returned accounts, and apply the same rule when no concurrent account needs appending.</violation>
</file>

<file name="packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts">

<violation number="1" location="packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts:118">
P1: When `credential` is a flat projected OAuth account, this makes `check()` report configured while the runtime still finds zero managed slots and uses ambient auth. The provider can therefore enter fallback but fail without the selected token; make runtime lane selection consume the same projected account or align availability with it.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/ai/src/auth/pool/slots.ts Outdated
if (hasProviderOwnedPool(flat)) {
if (flat.accounts.length === 0 || !current || !Array.isArray(current.accounts)) return flat;
const returnedNames = new Set(flat.accounts.map((slot) => slot.name));
const concurrentAccounts = current.accounts.filter((slot) => !returnedNames.has(slot.name));

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: When two provider-owned logins start with no stored accounts, both results use default, so this filter drops the first login's distinct account and the later return flat overwrites it. Reconcile same-name slots by material or assign a unique name after reading the serialized latest state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/ai/src/auth/pool/slots.ts, line 159:

<comment>When two provider-owned logins start with no stored accounts, both results use `default`, so this filter drops the first login's distinct account and the later `return flat` overwrites it. Reconcile same-name slots by material or assign a unique name after reading the serialized latest state.</comment>

<file context>
@@ -140,12 +140,27 @@ function nextLoginSlotName(credential: PooledCredential): string {
+	if (hasProviderOwnedPool(flat)) {
+		if (flat.accounts.length === 0 || !current || !Array.isArray(current.accounts)) return flat;
+		const returnedNames = new Set(flat.accounts.map((slot) => slot.name));
+		const concurrentAccounts = current.accounts.filter((slot) => !returnedNames.has(slot.name));
+		if (concurrentAccounts.length === 0) return flat;
+		const merged: PooledCredential = { ...flat, accounts: [...flat.accounts, ...concurrentAccounts] };
</file context>

const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx));
const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length;
const accountCount = storedAccounts.length + environmentTokenCount;
const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount;

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: When credential is a flat projected OAuth account, this makes check() report configured while the runtime still finds zero managed slots and uses ambient auth. The provider can therefore enter fallback but fail without the selected token; make runtime lane selection consume the same projected account or align availability with it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts, line 118:

<comment>When `credential` is a flat projected OAuth account, this makes `check()` report configured while the runtime still finds zero managed slots and uses ambient auth. The provider can therefore enter fallback but fail without the selected token; make runtime lane selection consume the same projected account or align availability with it.</comment>

<file context>
@@ -109,9 +109,13 @@ export function createOAuthConfig(deps: {
 		const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx));
 		const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length;
-		const accountCount = storedAccounts.length + environmentTokenCount;
+		const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount;
 		const settings = deps.readSettings?.();
 		const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient");
</file context>

Comment thread packages/ai/src/auth/pool/slots.ts Outdated
const returnedNames = new Set(flat.accounts.map((slot) => slot.name));
const concurrentAccounts = current.accounts.filter((slot) => !returnedNames.has(slot.name));
if (concurrentAccounts.length === 0) return flat;
const merged: PooledCredential = { ...flat, accounts: [...flat.accounts, ...concurrentAccounts] };

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: When an account is pinned or unpinned during the OAuth flow, this merge copies the login's stale pinned value over the latest stored value. Preserve current pool metadata while merging the returned accounts, and apply the same rule when no concurrent account needs appending.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/ai/src/auth/pool/slots.ts, line 161:

<comment>When an account is pinned or unpinned during the OAuth flow, this merge copies the login's stale `pinned` value over the latest stored value. Preserve current pool metadata while merging the returned accounts, and apply the same rule when no concurrent account needs appending.</comment>

<file context>
@@ -140,12 +140,27 @@ function nextLoginSlotName(credential: PooledCredential): string {
+		const returnedNames = new Set(flat.accounts.map((slot) => slot.name));
+		const concurrentAccounts = current.accounts.filter((slot) => !returnedNames.has(slot.name));
+		if (concurrentAccounts.length === 0) return flat;
+		const merged: PooledCredential = { ...flat, accounts: [...flat.accounts, ...concurrentAccounts] };
+		return merged;
+	}
</file context>

eddieparc and others added 10 commits September 3, 2026 17:12
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
…essage[]

The compaction-summary fixture is a session AgentMessage, not a pi-ai Message;
the root tsc run (which includes tests) rejected the narrower parameter type.
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
…ifecycle and compaction regressions

Both regression files carried the same session/extension fixture; hoisting it
into test/helpers/claude-sdk-oauth-restart-fixture.ts keeps each file under the
250 pure-LOC ceiling and lets the two suites drift together.
…ts marker

A closed-entry fallback whose remembered binding does not match the current
branch used to append a binding marker and then skip the sidecar write; that
marker-only entry retired the still-valid older sidecar on the next restart.
Build the candidate record first and touch the branch only when it validates.
The #6981 fixtures now project the branch through the real
buildSessionContext(), so the compaction regression proves the persisted digest
equals the one admission computes instead of asserting a hand-supplied context.
@code-yeongyu
code-yeongyu force-pushed the fix/claude-session-persistence branch from 6d1df6d to 6d77078 Compare September 3, 2026 08:12
…he compaction re-anchor regression

The compaction fixture named a post-boundary entry as firstKeptEntryId, so the
expected projection came from the unconditional post-compaction slice rather
than from selecting a kept pre-boundary entry. The branch now carries a
summarised turn, a kept pre-boundary user, the compaction naming it, and a
post-boundary user; the test asserts the projected digest covers exactly the
summary + kept + later turns and excludes the summarised one.
@code-yeongyu
code-yeongyu merged commit d43b36c into code-yeongyu:main Sep 3, 2026
21 checks passed
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.

2 participants