Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Moving the model selector off a Claude model and back no longer re-sends the whole conversation. Leaving the `claude-sdk-oauth` provider now closes the live SDK session but keeps its continuity binding - the same thing a thinking-level change already did - so returning to the same Claude model re-attaches to the existing session and sends only the messages added while you were away, instead of paying for the full history again. Switching to a different Claude model, a restart with no usable transcript, a diverged conversation and an unacknowledged session id all still start fresh exactly as before. When a binding really was discarded, the transcript notice now names the recorded cause (`model_selected`, `tainted_compaction`, `branch_diverged`, `extensions_removed`, ...) instead of the generic `registry_miss`, which from now on means only "no record was ever found" ([#1747](https://github.com/code-yeongyu/senpi/issues/1747)).

- A provider that accepts a request and never starts streaming no longer ends the turn with the watchdog's own message (`Provider stream start timed out after 180000ms ...`). The stall is still retried on the same model with the configured stream-start bound, and still hands the turn to the next model in a configured `retry.fallbackChains` entry whose answer becomes the turn result. What changed is what you read: the transcript (and `senpi -p`) describes the stall in plain language, and when nothing can take the turn over the final line names the stalled model, the attempts spent and the next step - `/fallback`, resending, or raising `retry.provider.streamStartTimeoutMs` (`0` disables). The wording on the assistant message is unchanged, so retry classification and fallback routing behave exactly as before ([#1740](https://github.com/code-yeongyu/senpi/issues/1740)).

- A provider that keeps streaming at a uselessly low rate is now detected instead of looking healthy forever. Every previous guard on a live stream watched for silence (the stream-start bound stops applying at the first event; the idle bound is re-armed by every event), so a turn crawling at ~2 tok/s never failed, never retried and never walked a fallback chain. After the first stream event senpi now ignores `retry.provider.throughputGraceMs` (default 5000) of streaming and then measures streamed text and thinking units over a trailing `retry.provider.throughputWindowMs` (default 20000); a full window carrying at least 16 units whose sustained rate is below `retry.provider.minThroughputTokensPerSecond` (default 8, `0` disables) aborts the request with `Provider stream throughput degraded: <n> tok/s over <n>s (floor <n> tok/s)`. That failure is retryable but spends no same-model attempts - replaying the payload cannot make the upstream faster - so it goes straight to the configured fallback chain, and with no candidate the turn ends on that error with the usual "No fallback chain configured - set one with /fallback." guidance instead of continuing to crawl. Time the provider spends running local tools is excluded from the measurement, and the interactive working line now shows the live rate (`Working (1m 12s - 2.1 tok/s - esc to interrupt)`) so a degraded turn is visible while it runs ([#1739](https://github.com/code-yeongyu/senpi/issues/1739)).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# claude-sdk-oauth

## 2026-09-16 - Keep the binding across a provider excursion and report the recorded invalidation cause (senpi#1747)

### What changed

- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-registry-wiring.ts`: the `model_select` handler no longer invalidates when the newly selected model belongs to another provider. It now uses the existing `keepBindingThenClose`, exactly like `thinking_level_select` and the in-provider `switchSessionModel` failure branch, so the live SDK session closes while the binding and its sidecar survive. The same file now also carries the ledger invalidation cause into process memory: `persistBindingInvalidation` records it as it appends the record, `session_start` re-reads it from the branch on a restart (clearing it for `new`), and the `message_end` marker retires it.
- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts`: adds `invalidationReasonFromBranch`, which returns the reason of the newest binding ledger record when that record is an invalidation (a later marker retires it).
- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-reattach.ts`: holds the pending invalidation reason per senpi session id (`rememberBindingInvalidation`, `bindingInvalidationReason`) next to the binding map it replaces.
- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts`: `ContinuityDecisionInput` accepts `invalidationReason`, and a `bootstrap`/`flatten` that would report the no-record default `registry_miss` reports the recorded cause instead (`model_selected`, `extensions_removed`, `assistant_rewritten` pass through the observation vocabulary; `compaction`, `tree_changed` and `fork` map to `tainted_compaction`, `branch_diverged` and `tainted_fork`). Classification is unchanged: no decision kind moves, and every other reason is left as decided.
- `packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-stream.ts`: passes the pending reason into the decision input.

### Why

- Cycling the model selector away from Claude and back re-sent the whole conversation (field reports of hundreds of KB per turn, one measured at 226 messages / ~881 KB) because the excursion destroyed a perfectly resumable binding. The module already had the non-destructive path for exactly this situation; the reattach path handles the return trip through `sentPrefixHash` / `commonPrefixLength` with `from: binding.sentCount`, so only the messages added while away are sent. Every safety net still decides the return trip: `identityDrift` flattens on `model_changed`, an unconfirmed SDK session id is refused, and a missing transcript or a diverged sent stream still flattens.
- The invalidation reason was written to the ledger and never read back, so a genuine invalidation (`model_selected`, `compaction`, `tree_changed`, `extensions_removed`) surfaced to the user as `registry_miss` - the reason that is supposed to mean "no record was ever found".

### Why an extension could not handle it

- The model-selector lifecycle wiring, the binding ledger and the continuity decision table are this provider extension's own internals; no extension surface can observe or replace them.

### Expected merge conflict zones

- MEDIUM: the `session_start` and `model_select` handlers in `session-registry-wiring.ts`.
- LOW: the binding map in `session-reattach.ts`, the branch readers in `session-binding.ts`, the decision-input type and the `decideNativeContinuity` entry point in `session-continuity.ts`, the decision-input construction in `session-stream.ts`.

## 2026-09-11 - Detect same-tick settings rewrites

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ export function storedBindingFromBinding(
};
}

/**
* Reason carried by the newest binding ledger record, when that record is an
* invalidation. A marker appended later retires it: the marker means a turn
* re-established the binding, so no cause is pending any more.
*/
export function invalidationReasonFromBranch(branch: readonly BranchEntry[]): string | undefined {
const index = newestBindingEntryIndex(branch);
if (index < 0) return undefined;
const data = branch[index]?.data;
return isBindingInvalidation(data) ? data.reason : undefined;
}

function isBindingInvalidation(value: unknown): value is BindingInvalidation {
if (typeof value !== "object" || value === null) return false;
return "invalidated" in value && value.invalidated === true && "reason" in value && typeof value.reason === "string";
}

export function bindingFromStoredBranch(
branch: readonly BranchEntry[],
stored: StoredBinding,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ContinuityReason } from "./session-observability.ts";
import { type ContinuityReason, sanitizeReason } from "./session-observability.ts";
import { sentHashPrefixDigest } from "./session-sync.ts";

export type ContinuityEntrySnapshot = {
Expand Down Expand Up @@ -42,10 +42,12 @@ export type ContinuityDecisionInput = {
/** false only on the config-dir lane, whose per-account credential roots cannot share a transcript root, so cross-account resume is impossible there. */
crossAccountResumeSupported: boolean;
idleExpired?: boolean;
/** Reason the newest ledger record invalidated this session's binding, when one is pending. */
invalidationReason?: string;
};

export type ContinuityDecision =
| { kind: "bootstrap" }
| { kind: "bootstrap"; reason?: ContinuityReason }
| { kind: "delta"; from: number }
| { kind: "reattach"; sdkSessionId: string; from: number; reason: ContinuityReason }
| { kind: "fork"; sdkSessionId: string; atUuid: string; from: number; reason: ContinuityReason }
Expand All @@ -56,6 +58,32 @@ const PENDING_FORK_REASONS: Readonly<Record<string, ContinuityReason>> = {
compaction: "tainted_compaction",
};

/**
* Ledger invalidation reasons that are not themselves observation vocabulary,
* mapped onto the closest member. Reasons that ARE members (model_selected,
* extensions_removed, assistant_rewritten) pass through `sanitizeReason`, which
* also keeps an unknown ledger string from ever reaching an observation.
*/
const INVALIDATION_CAUSES: Readonly<Record<string, ContinuityReason>> = {
compaction: "tainted_compaction",
tree_changed: "branch_diverged",
fork: "tainted_fork",
};

/**
* `registry_miss` means what it says: no record was ever found. When the ledger
* recorded WHY the binding went away, the cold-seed names that cause instead.
* Classification is untouched - only the reason a `bootstrap`/`flatten` reports
* changes, and only when it would otherwise be the no-record default.
*/
function withRecordedInvalidation(decision: ContinuityDecision, input: ContinuityDecisionInput): ContinuityDecision {
if (input.invalidationReason === undefined) return decision;
const cause = INVALIDATION_CAUSES[input.invalidationReason] ?? sanitizeReason(input.invalidationReason);
if (decision.kind === "bootstrap") return { kind: "bootstrap", reason: cause };
if (decision.kind === "flatten" && decision.reason === "registry_miss") return { kind: "flatten", reason: cause };
return decision;
}

function commonPrefixLength(left: readonly string[], right: readonly string[]): number {
const limit = Math.min(left.length, right.length);
let index = 0;
Expand Down Expand Up @@ -214,6 +242,10 @@ function decideFromBinding(input: ContinuityDecisionInput, binding: ContinuityBi
* session, new query).
*/
export function decideNativeContinuity(input: ContinuityDecisionInput): ContinuityDecision {
return withRecordedInvalidation(decideFromState(input), input);
}

function decideFromState(input: ContinuityDecisionInput): ContinuityDecision {
const { entry, binding } = input;
if (!entry) {
if (!binding) return { kind: "bootstrap" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ export function forgetBinding(senpiSessionId: string): void {
bindings.delete(senpiSessionId);
}

/**
* Reason the newest ledger record invalidated this session's binding. Held next
* to the binding it replaced so the next continuity decision can name the real
* cause instead of the no-record default: a restart re-reads it from the branch
* (session-registry-wiring), and a committed turn retires it with the marker.
*/
const bindingInvalidations = new Map<string, string>();

export function rememberBindingInvalidation(senpiSessionId: string, reason: string | undefined): void {
if (reason === undefined) bindingInvalidations.delete(senpiSessionId);
else bindingInvalidations.set(senpiSessionId, reason);
}

export function bindingInvalidationReason(senpiSessionId: string): string | undefined {
return bindingInvalidations.get(senpiSessionId);
}

export function bindingFromEntry(
entry: Pick<
ClaudeSdkOauthSessionEntry,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BINDING_MARKER,
type BindingInvalidation,
bindingFromStoredBranch,
invalidationReasonFromBranch,
storedBindingFromBinding,
storedBindingFromEntry,
} from "./session-binding.ts";
Expand All @@ -17,7 +18,13 @@ import {
isResidentAssistant,
isTerminalFailure,
} from "./session-commit-boundary.ts";
import { bindingFromEntry, forgetBinding, getBinding, rememberBinding } from "./session-reattach.ts";
import {
bindingFromEntry,
forgetBinding,
getBinding,
rememberBinding,
rememberBindingInvalidation,
} from "./session-reattach.ts";
import {
closeSession,
getSession,
Expand All @@ -29,8 +36,13 @@ import { sentHashesForEntry, sentMessageHashes, sentMessages } from "./session-s

const commitBoundary = new AssistantCommitBoundary();

function persistBindingInvalidation(pi: Partial<Pick<ExtensionAPI, "appendEntry">>, reason: string): void {
function persistBindingInvalidation(
pi: Partial<Pick<ExtensionAPI, "appendEntry">>,
sessionId: string,
reason: string,
): void {
pi.appendEntry?.(BINDING_ENTRY_TYPE, { schemaVersion: 1, invalidated: true, reason } satisfies BindingInvalidation);
rememberBindingInvalidation(sessionId, reason);
}

async function invalidateBinding(
Expand All @@ -42,7 +54,7 @@ async function invalidateBinding(
forgetBinding(sessionId);
const sessionFile = ctx.sessionManager.getSessionFile?.();
if (sessionFile) await deleteStoredBinding(sessionFile);
persistBindingInvalidation(pi, reason);
persistBindingInvalidation(pi, sessionId, reason);
}

function keepBindingThenClose(sessionId: string, reason: string): void {
Expand All @@ -64,21 +76,26 @@ export function registerSessionRegistry(
if (event.reason === "reload") return;
const sessionId = ctx.sessionManager.getSessionId();
forgetBinding(sessionId);
rememberBindingInvalidation(sessionId, undefined);
const sessionFile = ctx.sessionManager.getSessionFile?.();
if (event.reason === "new") return;
if (event.reason === "fork") {
if (sessionFile) await deleteStoredBinding(sessionFile);
persistBindingInvalidation(pi, "fork");
persistBindingInvalidation(pi, sessionId, "fork");
return;
}
if (!sessionFile) return;
// A restart carries the ledger, not the process maps: the newest binding record
// says whether a cause is still pending (invalidation) or was retired (marker).
const branch = ctx.sessionManager.getBranch();
rememberBindingInvalidation(sessionId, invalidationReasonFromBranch(branch));
const stored = await readStoredBinding(sessionFile);
if (!stored) return;
if (stored.sessionId !== sessionId) {
await deleteStoredBinding(sessionFile);
return;
}
const binding = bindingFromStoredBranch(ctx.sessionManager.getBranch(), stored);
const binding = bindingFromStoredBranch(branch, stored);
if (!binding) {
await deleteStoredBinding(sessionFile);
return;
Expand All @@ -100,9 +117,12 @@ export function registerSessionRegistry(
});
pi.on("model_select", async (event, ctx) => {
const sessionId = ctx.sessionManager.getSessionId();
// Leaving this provider is an excursion, not an invalidation: the live SDK
// session closes but the binding stays, so coming back to the same model
// reattaches at the recorded prefix instead of re-sending the whole
// conversation (senpi#1747). Identity drift still flattens on the way back.
if (event.model?.provider !== CLAUDE_SDK_OAUTH_PROVIDER_ID) {
closeSession(sessionId, "model_selected");
await invalidateBinding(pi, ctx, "model_selected");
keepBindingThenClose(sessionId, "model_selected");
return;
}
if (!(await switchSessionModel(sessionId, event.model.id))) {
Expand Down Expand Up @@ -160,6 +180,8 @@ export function registerSessionRegistry(
// not leave a marker-only entry that retires the still-valid older sidecar.
if (!recordFor("pending")) return;
pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER);
// The marker is now the newest ledger record, so any earlier cause is retired.
rememberBindingInvalidation(sessionId, undefined);
const markerEntryId = ctx.sessionManager.getLeafId();
if (!markerEntryId) return;
const stored = recordFor(markerEntryId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
sanitizeTerminalFailure,
stageContinuityDecision,
} from "./session-observability.ts";
import { bindingFromEntry, getBinding, reattachSession } from "./session-reattach.ts";
import { bindingFromEntry, bindingInvalidationReason, getBinding, reattachSession } from "./session-reattach.ts";
import {
type ClaudeSdkOauthSessionEntry,
closeSession,
Expand Down Expand Up @@ -98,6 +98,7 @@ async function createResidentAttempt(
transcriptAvailable,
crossAccountResumeSupported: auth.authLane !== "config-dir",
idleExpired: existing ? isIdleExpired(existing) : false,
invalidationReason: bindingInvalidationReason(sessionId),
});
const firstTurn =
existing === undefined && getBinding(sessionId) === undefined && !contextHasPriorAssistantMessage(input.context);
Expand Down
Loading
Loading