From 2014659a9f7ada1d63412a7fd82be547c7ef7349 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 30 Jul 2026 15:37:12 +0900 Subject: [PATCH 1/4] feat: across-turn Claude session reuse + ink-owned compaction reset (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2A of provider session reuse. Lifts the per-turn provider session seed (Stage 1) to session scope so consecutive turns reuse ONE Claude session: the first backend spawn seeds it (--session-id + full envelope); every later turn resumes it (--resume) sending only the delta (new user message + any passive-recall surfaced that turn). The whole interactive conversation becomes one coherent native jsonl — debuggable and readable in the provider TUI — instead of a fresh session (new jsonl) per turn. Ink owns compaction: when maybeCompactContext rolls the ledger, it also resets the live provider session id so the next turn seeds a FRESH native session with the compacted summary. The provider never runs its own compaction. Adds isResumeFailedNoSession (mirrors the server runners) so a resumed turn whose provider session vanished drops the live id and re-seeds next turn. Stateless backends (codex/gemini) keep the full-envelope-per-spawn path. Tests: mirror the seed/resume/compaction-reset decision + resume-not-found detector (9 tests). CLI type-check, build, and 440 backend/repl unit tests green. Co-Authored-By: Wren --- .../src/commands/chat.session-reuse.test.ts | 137 ++++++++++++++++++ packages/cli/src/commands/chat.ts | 97 ++++++++++--- 2 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/commands/chat.session-reuse.test.ts diff --git a/packages/cli/src/commands/chat.session-reuse.test.ts b/packages/cli/src/commands/chat.session-reuse.test.ts new file mode 100644 index 00000000..a9636c37 --- /dev/null +++ b/packages/cli/src/commands/chat.session-reuse.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { isResumeFailedNoSession } from './chat.js'; + +/** + * Stage 2A — across-turn provider session reuse. + * + * The seed/resume decision lives inside runUserTurn (chat.ts), a large closure + * that isn't unit-addressable directly. Mirror the exact decision as a pure + * helper here (same idiom as tool-loop stop decision) so the across-turn + + * compaction-reset behavior is guarded directly, plus test the exported + * resume-not-found detector against real backend stderr shapes. + */ + +// Mirrors chat.ts runUserTurn: first spawn of the session SEEDS a fresh +// provider session (full envelope); every later turn RESUMES it (delta only). +// A compaction resets activeId to undefined, forcing the next turn to re-seed. +function decideProviderSession( + canReuse: boolean, + activeId: string | undefined, + mintId: () => string +): { + seedId: string | undefined; + resumeId: string | undefined; + nextActiveId: string | undefined; + sendDelta: boolean; + sendFullEnvelope: boolean; +} { + const resume = canReuse && activeId !== undefined; + let seedId: string | undefined; + let nextActiveId = activeId; + if (canReuse && !resume) { + seedId = mintId(); + nextActiveId = seedId; + } + return { + seedId, + resumeId: resume ? activeId : undefined, + nextActiveId, + sendDelta: resume, + sendFullEnvelope: !resume, // seed turns and stateless backends re-pack the envelope + }; +} + +describe('across-turn provider session decision', () => { + const ids = ['S1', 'S2', 'S3']; + const minter = () => { + let i = 0; + return () => ids[i++]!; + }; + + it('first turn SEEDS a fresh session with the full envelope', () => { + const d = decideProviderSession(true, undefined, minter()); + expect(d.seedId).toBe('S1'); + expect(d.resumeId).toBeUndefined(); + expect(d.nextActiveId).toBe('S1'); + expect(d.sendDelta).toBe(false); + expect(d.sendFullEnvelope).toBe(true); + }); + + it('subsequent turn RESUMES the live session with delta only', () => { + const d = decideProviderSession(true, 'S1', minter()); + expect(d.seedId).toBeUndefined(); + expect(d.resumeId).toBe('S1'); + expect(d.nextActiveId).toBe('S1'); + expect(d.sendDelta).toBe(true); + expect(d.sendFullEnvelope).toBe(false); + }); + + it('stateless backends (canReuse=false) never seed/resume — always full envelope', () => { + const d = decideProviderSession(false, undefined, minter()); + expect(d.seedId).toBeUndefined(); + expect(d.resumeId).toBeUndefined(); + expect(d.nextActiveId).toBeUndefined(); + expect(d.sendDelta).toBe(false); + expect(d.sendFullEnvelope).toBe(true); + }); + + it('a multi-turn conversation reuses ONE session id across turns', () => { + const mint = minter(); + let active: string | undefined; + const used: Array<{ seed?: string; resume?: string; delta: boolean }> = []; + for (let turn = 0; turn < 3; turn++) { + const d = decideProviderSession(true, active, mint); + active = d.nextActiveId; + used.push({ seed: d.seedId, resume: d.resumeId, delta: d.sendDelta }); + } + // Turn 1 seeds S1; turns 2 and 3 resume S1 with deltas — one coherent jsonl. + expect(used[0]).toEqual({ seed: 'S1', resume: undefined, delta: false }); + expect(used[1]).toEqual({ seed: undefined, resume: 'S1', delta: true }); + expect(used[2]).toEqual({ seed: undefined, resume: 'S1', delta: true }); + }); + + it('ink-owned compaction rolls the provider session: next turn seeds a NEW id', () => { + const mint = minter(); + let active: string | undefined; + + // Turn 1: seed S1 + let d = decideProviderSession(true, active, mint); + active = d.nextActiveId; + expect(active).toBe('S1'); + + // Turn 2: resume S1 + d = decideProviderSession(true, active, mint); + expect(d.resumeId).toBe('S1'); + active = d.nextActiveId; + + // ink compacts the ledger → reset the live provider session id. + active = undefined; + + // Turn 3: with the summary in the ledger, seed a FRESH session (S2 ≠ S1). + d = decideProviderSession(true, active, mint); + expect(d.seedId).toBe('S2'); + expect(d.sendFullEnvelope).toBe(true); // fresh session gets the compacted summary + expect(d.nextActiveId).toBe('S2'); + }); +}); + +describe('isResumeFailedNoSession', () => { + it('detects claude "session not found"', () => { + expect(isResumeFailedNoSession('Error: session not found: abc-123')).toBe(true); + }); + + it('detects "No such session" case-insensitively', () => { + expect(isResumeFailedNoSession('No such session')).toBe(true); + expect(isResumeFailedNoSession('NO SUCH SESSION')).toBe(true); + }); + + it('is false for unrelated stderr (provider stall, econnreset)', () => { + expect(isResumeFailedNoSession('ECONNRESET while streaming')).toBe(false); + expect(isResumeFailedNoSession('request timed out')).toBe(false); + }); + + it('is false for empty/whitespace stderr', () => { + expect(isResumeFailedNoSession('')).toBe(false); + expect(isResumeFailedNoSession(' ')).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index cbbc6f8c..9197b8f7 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -2193,6 +2193,16 @@ function formatBootstrapContext(result: Record, agentId: string return sections.join('\n\n'); } +/** + * Detect claude's "resume failed because the session no longer exists locally" + * signal from stderr. Mirrors the same check in the server runners + * (ink-runner.ts / claude-runner.ts) so the CLI recovers the same way. + */ +export function isResumeFailedNoSession(stderr: string): boolean { + const lower = (stderr || '').toLowerCase(); + return lower.includes('session not found') || lower.includes('no such session'); +} + function buildPromptEnvelope( agentId: string, runtime: ChatRuntime, @@ -3237,6 +3247,16 @@ export async function runChat(options: ChatOptions): Promise { // `compaction` transcript event is the pointer to the new start state — // hydration collapses everything before it on reattach. If summarization // fails, fall back to a hard trim so the turn can still proceed. + // ── Across-turn provider session reuse (claude only) ── + // The live provider-native session id for THIS ink process. Seeded on the + // first backend spawn and RESUMED on every subsequent turn so the whole + // conversation is ONE coherent native jsonl — debuggable, and readable in the + // provider's own TUI — instead of a fresh session (new jsonl) per turn. Reset + // to undefined at the ink-owned compaction boundary below so the next turn + // mints a fresh provider session seeded with the compacted summary: ink owns + // compaction, the provider never runs its own. + const canReuseBackendSession = runtime.backend === 'claude'; + let activeBackendSessionId: string | undefined; let compactionInFlight = false; const buildCompactionPrompt = (chunk: string): string => @@ -3332,6 +3352,11 @@ export async function runChat(options: ChatOptions): Promise { } } finally { compactionInFlight = false; + // ink just rolled the ledger — roll the provider session too so the next + // turn seeds a fresh native session with the summary (we compact before + // the provider ever would). No-op when nothing was compacted: the early + // returns above never reach this block. + if (canReuseBackendSession) activeBackendSessionId = undefined; } }; @@ -3862,19 +3887,35 @@ export async function runChat(options: ChatOptions): Promise { } } - let prompt = buildPromptEnvelope(agentId, runtime, ledger, raw); + // Provider session seed/resume decision (claude only). The first backend + // spawn of the session SEEDS a fresh provider session (--session-id) with + // the FULL envelope; every later turn RESUMES it (--resume) sending only + // this turn's delta — the new user message plus any passive-recall surfaced + // this turn — because the provider already holds the system prompt, tools, + // bootstrap, and prior turns. The tool-loop continuations below always + // resume the same session. This collapses the whole conversation into ONE + // coherent Claude jsonl and stops re-piping the transcript window on every + // round-trip. Stateless backends (codex/gemini) always get the full + // envelope. `canReuseBackendSession`/`activeBackendSessionId` are + // session-scoped (declared above) so reuse spans turns and resets at the + // compaction boundary. + const resumeProviderSession = canReuseBackendSession && activeBackendSessionId !== undefined; + let seedProviderSessionId: string | undefined; + if (canReuseBackendSession && !resumeProviderSession) { + seedProviderSessionId = randomUUID(); + activeBackendSessionId = seedProviderSessionId; + } - // Within-turn backend session reuse (claude only). Seed ONE backend-native - // session id for this turn: the first spawn creates it (--session-id) with - // the full envelope, and every tool-loop continuation resumes it (--resume) - // sending only the tool-results delta. This collapses a turn's N tool - // round-trips into a single coherent Claude session — the jsonl becomes the - // real thread (debuggable) instead of N fragments — and stops re-piping the - // whole transcript window on every round-trip. Other backends keep the - // stateless full-envelope-per-spawn behavior (codex/gemini can't seed a - // session id up front the way claude's --session-id allows). - const canReuseBackendSession = runtime.backend === 'claude'; - const backendSeedId = canReuseBackendSession ? randomUUID() : undefined; + let prompt: string; + if (resumeProviderSession) { + const recallDelta = promptHookResult.injectedEntries + .filter((e) => e.source === 'passive-recall') + .map((e) => e.content) + .join('\n\n'); + prompt = recallDelta ? `${recallDelta}\n\n${raw}` : raw; + } else { + prompt = buildPromptEnvelope(agentId, runtime, ledger, raw); + } const turnStartedAt = Date.now(); const backendGate = toolPolicy.getBackendToolGate(); @@ -3966,8 +4007,12 @@ export async function runChat(options: ChatOptions): Promise { passthroughArgs, timeoutMs: runtime.backendTurnTimeoutMs, attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, - // Seed the turn's backend session so tool-loop continuations can resume it. - ...(backendSeedId ? { backendSessionSeedId: backendSeedId } : {}), + // Seed a fresh provider session (first spawn) OR resume the live one + // (subsequent turns). Tool-loop continuations below always resume it. + ...(seedProviderSessionId ? { backendSessionSeedId: seedProviderSessionId } : {}), + ...(resumeProviderSession && activeBackendSessionId + ? { backendSessionId: activeBackendSessionId } + : {}), }); currentTurnAbort = turn.abort; inkRepl?.setAbortHandler(abortCurrentTurn); @@ -3979,6 +4024,17 @@ export async function runChat(options: ChatOptions): Promise { turnDurationSeconds = Math.max(0, Math.round((Date.now() - turnStartedAt) / 1000)); stopWaiting(); }); + // If a resumed turn failed because the provider session vanished (jsonl + // cleaned up / different machine), drop the live id so the NEXT turn seeds a + // fresh one. Within a single interactive process this is near-impossible (we + // seeded the id ourselves); the full mid-turn re-seed lands with the + // server/cross-process path. + if (resumeProviderSession && !runResult.success && isResumeFailedNoSession(runResult.stderr)) { + activeBackendSessionId = undefined; + printEvent( + chalk.yellow(' ⛁ provider session not found on resume — will re-seed on the next turn') + ); + } sbDebugLog( 'chat', 'backend_turn_result', @@ -4391,9 +4447,10 @@ export async function runChat(options: ChatOptions): Promise { // transcript + tool instructions from the seeded turn — send ONLY the // delta. Otherwise (stateless backends) re-pack the full envelope so the // fresh spawn has the context it needs. - const continuationPrompt = canReuseBackendSession - ? continuationBody - : buildPromptEnvelope(agentId, runtime, ledger, continuationBody); + const continuationPrompt = + canReuseBackendSession && activeBackendSessionId + ? continuationBody + : buildPromptEnvelope(agentId, runtime, ledger, continuationBody); // Show continuation indicator naming the tools that just ran — this is // the SB working, not a system message @@ -4423,9 +4480,9 @@ export async function runChat(options: ChatOptions): Promise { passthroughArgs, timeoutMs: runtime.backendTurnTimeoutMs, attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, - // Resume the turn's seeded backend session so this round-trip appends to - // the same Claude thread instead of re-piping the whole window. - ...(backendSeedId ? { backendSessionId: backendSeedId } : {}), + // Resume the live provider session so this round-trip appends to the + // same Claude thread instead of re-piping the whole window. + ...(activeBackendSessionId ? { backendSessionId: activeBackendSessionId } : {}), }); currentTurnAbort = contTurn.abort; inkRepl?.setAbortHandler(abortCurrentTurn); From 5647ce23d9f957c4b3ca9a3413834d33e69e0332 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 30 Jul 2026 15:55:58 +0900 Subject: [PATCH 2/4] feat: cross-process provider session reuse for server/Myra heartbeats (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2B — completes provider session reuse for the server path. Each Myra heartbeat is a separate `ink chat --non-interactive` process (one message), so 2A's across-turn reuse doesn't help it: every heartbeat seeded a fresh Claude session = a new jsonl = the opacity Conor hit (piped messages don't show in the thread). Fix, self-contained in chat.ts (no server/InkRunner changes): persist the live provider session id as a `backend_session` transcript marker on seed, and recover it on reattach via findLastBackendSessionId. Since the ink transcript is keyed by pcp session id and reattached across processes, the next heartbeat RESUMES the same native session and the jsonl accumulates one coherent thread. A `compaction` marker clears the recovered candidate so a post-compaction process starts fresh with the summary (never drags the pre-compaction window back) — ink owns compaction, the provider never runs its own. Resume-not-found now re-seeds mid-turn (mirrors ClaudeRunner/InkRunner) so a stale recovered id still produces output. Validated e2e: two separate `ink chat --non-interactive` invocations on one session id → invocation 2 recalled the codeword planted in invocation 1, NO new jsonl was created (resumed), and the single jsonl holds both turns (["OK", "WREN-XPROC-7734"]). Unit: findLastBackendSessionId incl. the compaction-clears-candidate logic (6 tests). CLI type-check, build, 485 tests green. Co-Authored-By: Wren --- .../src/commands/chat.session-reuse.test.ts | 82 ++++++++++++++- packages/cli/src/commands/chat.ts | 99 ++++++++++++++++--- 2 files changed, 167 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/chat.session-reuse.test.ts b/packages/cli/src/commands/chat.session-reuse.test.ts index a9636c37..bd797828 100644 --- a/packages/cli/src/commands/chat.session-reuse.test.ts +++ b/packages/cli/src/commands/chat.session-reuse.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { isResumeFailedNoSession } from './chat.js'; +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { isResumeFailedNoSession, findLastBackendSessionId } from './chat.js'; /** * Stage 2A — across-turn provider session reuse. @@ -135,3 +138,78 @@ describe('isResumeFailedNoSession', () => { expect(isResumeFailedNoSession(' ')).toBe(false); }); }); + +describe('findLastBackendSessionId (cross-process recovery)', () => { + let dir: string; + const writeTranscript = (events: object[]): string => { + dir = mkdtempSync(join(tmpdir(), 'sb-transcript-')); + const path = join(dir, 'transcript.jsonl'); + writeFileSync(path, events.map((e) => JSON.stringify(e)).join('\n') + '\n'); + return path; + }; + + afterEach(() => { + if (dir) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + } + }); + + it('returns undefined when the transcript file does not exist', () => { + expect(findLastBackendSessionId(join(tmpdir(), 'does-not-exist-xyz.jsonl'))).toBeUndefined(); + expect(findLastBackendSessionId('')).toBeUndefined(); + }); + + it('recovers the last backend_session id so the next process resumes it', () => { + const path = writeTranscript([ + { type: 'user', content: 'hi' }, + { type: 'backend_session', id: 'sess-1' }, + { type: 'user', content: 'more' }, + { type: 'backend_session', id: 'sess-2' }, + ]); + expect(findLastBackendSessionId(path)).toBe('sess-2'); + }); + + it('returns undefined when there is no backend_session marker', () => { + const path = writeTranscript([ + { type: 'user', content: 'hi' }, + { type: 'system_turn', content: 'heartbeat' }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a compaction AFTER the last seed clears the candidate (roll to fresh)', () => { + // ink compacted and the process ended before seeding again — the next + // process must NOT resume the pre-compaction session (it would drag the + // pre-compaction window back in). Seed fresh instead. + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-compaction' }, + { type: 'user', content: 'lots of turns' }, + { type: 'compaction', summary: '[summary]' }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a seed AFTER a compaction is the live session (re-established)', () => { + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-compaction' }, + { type: 'compaction', summary: '[summary]' }, + { type: 'backend_session', id: 'post-compaction' }, + { type: 'user', content: 'next turn' }, + ]); + expect(findLastBackendSessionId(path)).toBe('post-compaction'); + }); + + it('ignores malformed lines', () => { + dir = mkdtempSync(join(tmpdir(), 'sb-transcript-')); + const path = join(dir, 'transcript.jsonl'); + writeFileSync( + path, + ['not json', JSON.stringify({ type: 'backend_session', id: 'ok-1' }), '', '{bad'].join('\n') + ); + expect(findLastBackendSessionId(path)).toBe('ok-1'); + }); +}); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 9197b8f7..7527bec9 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -2203,6 +2203,41 @@ export function isResumeFailedNoSession(stderr: string): boolean { return lower.includes('session not found') || lower.includes('no such session'); } +/** + * Recover the live provider-native session id from a reattached transcript so a + * fresh process (the next server heartbeat, or a reattach) resumes the SAME + * native session instead of fragmenting into a new jsonl. Returns the id of the + * last `backend_session` marker. A `compaction` marker clears the candidate: + * after ink compacts we deliberately roll to a fresh provider session, so a + * pre-compaction id must never be resumed (it would drag the pre-compaction + * window back in). + */ +export function findLastBackendSessionId(transcriptPath: string): string | undefined { + if (!transcriptPath || !existsSync(transcriptPath)) return undefined; + let content: string; + try { + content = readFileSync(transcriptPath, 'utf-8'); + } catch { + return undefined; + } + let found: string | undefined; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + let event: { type?: unknown; id?: unknown }; + try { + event = JSON.parse(line) as { type?: unknown; id?: unknown }; + } catch { + continue; + } + if (event.type === 'backend_session' && typeof event.id === 'string') { + found = event.id; + } else if (event.type === 'compaction') { + found = undefined; + } + } + return found; +} + function buildPromptEnvelope( agentId: string, runtime: ChatRuntime, @@ -2817,9 +2852,27 @@ export async function runChat(options: ChatOptions): Promise { ? findLatestTranscriptForSession(runtime.sessionId) : undefined; runtime.transcriptPath = existingTranscript || ensureRuntimeTranscriptPath(runtime.sessionId); + + // ── Provider session reuse (claude only) — Stage 2 ── + // One provider-native session id per ink session, reused across turns AND + // across processes. Seeded on the first backend spawn, resumed thereafter, + // and reset at the ink-owned compaction boundary. Recovered from the + // reattached transcript below so a fresh process (e.g. the next Myra + // heartbeat, which reattaches the same pcp session) RESUMES the same native + // session — the jsonl accumulates one coherent thread instead of fragmenting + // into a new file per message. ink owns compaction; the provider never runs + // its own. + const canReuseBackendSession = runtime.backend === 'claude'; + let activeBackendSessionId: string | undefined; + let historyHydration: HistoryHydrationResult | null = null; if (attachedToExistingSession && existingTranscript) { const hydrated = hydrateLedgerFromTranscript(ledger, existingTranscript); + // Resume the provider session the prior process left live (delta only), + // unless a compaction rolled it — then the next turn seeds a fresh one. + if (canReuseBackendSession) { + activeBackendSessionId = findLastBackendSessionId(existingTranscript); + } // Continue the event-id sequence from where the file left off seedTranscriptEidCounter(existingTranscript, hydrated.maxEid); sessionEvictedEntries.push(...hydrated.evictedEntries); @@ -3247,16 +3300,6 @@ export async function runChat(options: ChatOptions): Promise { // `compaction` transcript event is the pointer to the new start state — // hydration collapses everything before it on reattach. If summarization // fails, fall back to a hard trim so the turn can still proceed. - // ── Across-turn provider session reuse (claude only) ── - // The live provider-native session id for THIS ink process. Seeded on the - // first backend spawn and RESUMED on every subsequent turn so the whole - // conversation is ONE coherent native jsonl — debuggable, and readable in the - // provider's own TUI — instead of a fresh session (new jsonl) per turn. Reset - // to undefined at the ink-owned compaction boundary below so the next turn - // mints a fresh provider session seeded with the compacted summary: ink owns - // compaction, the provider never runs its own. - const canReuseBackendSession = runtime.backend === 'claude'; - let activeBackendSessionId: string | undefined; let compactionInFlight = false; const buildCompactionPrompt = (chunk: string): string => @@ -3904,6 +3947,12 @@ export async function runChat(options: ChatOptions): Promise { if (canReuseBackendSession && !resumeProviderSession) { seedProviderSessionId = randomUUID(); activeBackendSessionId = seedProviderSessionId; + // Persist the seed so a later process (next heartbeat / reattach) recovers + // and RESUMES this native session instead of fragmenting into a new jsonl. + appendTranscript(runtime.transcriptPath, { + type: 'backend_session', + id: seedProviderSessionId, + }); } let prompt: string; @@ -4030,10 +4079,36 @@ export async function runChat(options: ChatOptions): Promise { // seeded the id ourselves); the full mid-turn re-seed lands with the // server/cross-process path. if (resumeProviderSession && !runResult.success && isResumeFailedNoSession(runResult.stderr)) { - activeBackendSessionId = undefined; + // The resumed provider session no longer exists locally (jsonl pruned / + // different machine). Mint a fresh native session, re-send the FULL + // envelope (the ledger already holds the history), and retry once so a + // server heartbeat still produces output instead of dying on a stale id. + // Mirrors ClaudeRunner/InkRunner's resume-not-found recovery. + const reseedId = randomUUID(); + activeBackendSessionId = reseedId; + appendTranscript(runtime.transcriptPath, { type: 'backend_session', id: reseedId }); printEvent( - chalk.yellow(' ⛁ provider session not found on resume — will re-seed on the next turn') + chalk.yellow(' ⛁ provider session not found on resume — re-seeding a fresh native session') ); + process.on('SIGINT', onSigintDuringTurn); + const reseedTurn = startBackendTurn({ + backend: runtime.backend, + agentId, + model: runtime.model, + prompt: buildPromptEnvelope(agentId, runtime, ledger, raw), + verbose: runtime.verbose, + passthroughArgs, + timeoutMs: runtime.backendTurnTimeoutMs, + attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, + backendSessionSeedId: reseedId, + }); + currentTurnAbort = reseedTurn.abort; + inkRepl?.setAbortHandler(abortCurrentTurn); + runResult = await reseedTurn.result.finally(() => { + currentTurnAbort = null; + inkRepl?.setAbortHandler(null); + process.off('SIGINT', onSigintDuringTurn); + }); } sbDebugLog( 'chat', From 2f4ba50ea84dea3c962c9471113fdfb3c8439584 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 30 Jul 2026 16:17:37 +0900 Subject: [PATCH 3/4] fix: invalidate provider session on backend switch + context-boundary mutations (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Lumen's two P1 findings on #446. P1#1 (backend switch): canReuseBackendSession was captured once at startup, but /backend mutates runtime.backend — so claude→codex still resumed the Claude UUID with delta-only, and claude→other→claude skipped intervening turns. Now compute canReuseBackendSession PER-TURN against the current backend, and tag the live session with the backend that seeded it (activeBackendSessionBackend). A mismatch invalidates the session so we reseed fresh instead of resuming across a backend boundary. P1#2 (context-boundary mutations): the provider session only rolled on auto-compaction. /trim, /evict, and evict_context change ink's window but the resumed Claude session still held the evicted content, and cross-process recovery ignored context_evict/context_trim markers. Now roll the provider session inside recordEviction (the single writer all three route through), and clear the recovered candidate in findLastBackendSessionId on context_evict/context_trim (alongside compaction). Evicted content can no longer linger in a resumed native session or survive reattach. Tests: +6 (backend-ownership invalidation incl. claude→codex→claude reseed; recovery clearing on evict/trim). Re-verified 2B cross-process reuse still works e2e (recall across processes, no new jsonl). CLI type-check, build, full unit suite green (968 passed/4 skipped). Co-Authored-By: Wren --- .../src/commands/chat.session-reuse.test.ts | 103 ++++++++++++++++++ packages/cli/src/commands/chat.ts | 63 ++++++++--- 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/chat.session-reuse.test.ts b/packages/cli/src/commands/chat.session-reuse.test.ts index bd797828..b49c6e30 100644 --- a/packages/cli/src/commands/chat.session-reuse.test.ts +++ b/packages/cli/src/commands/chat.session-reuse.test.ts @@ -203,6 +203,34 @@ describe('findLastBackendSessionId (cross-process recovery)', () => { expect(findLastBackendSessionId(path)).toBe('post-compaction'); }); + it('a context_evict AFTER the last seed clears the candidate', () => { + // /evict or evict_context removed entries; a resumed native session would + // still hold the evicted content, so recovery must NOT resume it. + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-evict' }, + { type: 'user', content: 'stuff' }, + { type: 'context_evict', actor: 'sb', refs: [{ hash: 'h' }] }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a context_trim AFTER the last seed clears the candidate', () => { + const path = writeTranscript([ + { type: 'backend_session', id: 'pre-trim' }, + { type: 'context_trim', reason: 'manual' }, + ]); + expect(findLastBackendSessionId(path)).toBeUndefined(); + }); + + it('a seed AFTER an eviction is the live session (re-established)', () => { + const path = writeTranscript([ + { type: 'backend_session', id: 'old' }, + { type: 'context_evict', actor: 'user', refs: [{ hash: 'h' }] }, + { type: 'backend_session', id: 'post-evict' }, + ]); + expect(findLastBackendSessionId(path)).toBe('post-evict'); + }); + it('ignores malformed lines', () => { dir = mkdtempSync(join(tmpdir(), 'sb-transcript-')); const path = join(dir, 'transcript.jsonl'); @@ -213,3 +241,78 @@ describe('findLastBackendSessionId (cross-process recovery)', () => { expect(findLastBackendSessionId(path)).toBe('ok-1'); }); }); + +// Mirrors runUserTurn's backend-ownership check: a live provider session is +// invalidated when the current backend differs from the one that seeded it, so +// a /backend switch never resumes a Claude UUID under codex/gemini and never +// skips intervening turns on claude→other→claude. +function decideWithBackend( + currentBackend: string, + activeId: string | undefined, + activeOwner: string | undefined, + mintId: () => string +): { + invalidated: boolean; + resume: boolean; + seedId: string | undefined; + nextActiveId: string | undefined; + nextOwner: string | undefined; +} { + let id = activeId; + let owner = activeOwner; + let invalidated = false; + if (id !== undefined && owner !== currentBackend) { + id = undefined; + owner = undefined; + invalidated = true; + } + const canReuse = currentBackend === 'claude'; + const resume = canReuse && id !== undefined; + let seedId: string | undefined; + if (canReuse && !resume) { + seedId = mintId(); + id = seedId; + owner = currentBackend; + } + return { invalidated, resume, seedId, nextActiveId: id, nextOwner: owner }; +} + +describe('provider session backend-ownership (mid-session /backend switch)', () => { + const minter = () => { + const ids = ['S1', 'S2', 'S3']; + let i = 0; + return () => ids[i++]!; + }; + + it('claude→codex invalidates the Claude session and does not resume it', () => { + const mint = minter(); + // Seed on claude. + let d = decideWithBackend('claude', undefined, undefined, mint); + expect(d.seedId).toBe('S1'); + // Switch to codex: the Claude UUID is invalidated, codex does not reuse. + d = decideWithBackend('codex', d.nextActiveId, d.nextOwner, mint); + expect(d.invalidated).toBe(true); + expect(d.resume).toBe(false); + expect(d.seedId).toBeUndefined(); + expect(d.nextActiveId).toBeUndefined(); + }); + + it('claude→codex→claude reseeds fresh (never resumes the pre-switch session)', () => { + const mint = minter(); + let d = decideWithBackend('claude', undefined, undefined, mint); // S1 + const onCodex = decideWithBackend('codex', d.nextActiveId, d.nextOwner, mint); + d = decideWithBackend('claude', onCodex.nextActiveId, onCodex.nextOwner, mint); + expect(d.resume).toBe(false); + expect(d.seedId).toBe('S2'); // fresh, not S1 — intervening codex turns aren't in S1 + expect(d.nextOwner).toBe('claude'); + }); + + it('claude→claude resumes the same session (no spurious invalidation)', () => { + const mint = minter(); + const d1 = decideWithBackend('claude', undefined, undefined, mint); // S1 + const d2 = decideWithBackend('claude', d1.nextActiveId, d1.nextOwner, mint); + expect(d2.invalidated).toBe(false); + expect(d2.resume).toBe(true); + expect(d2.nextActiveId).toBe('S1'); + }); +}); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 7527bec9..d1ffeb2c 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -2231,7 +2231,13 @@ export function findLastBackendSessionId(transcriptPath: string): string | undef } if (event.type === 'backend_session' && typeof event.id === 'string') { found = event.id; - } else if (event.type === 'compaction') { + } else if ( + event.type === 'compaction' || + event.type === 'context_evict' || + event.type === 'context_trim' + ) { + // A context-boundary mutation rolled the provider session. Abandon any + // prior id — a backend_session marker after this point re-establishes it. found = undefined; } } @@ -2856,22 +2862,27 @@ export async function runChat(options: ChatOptions): Promise { // ── Provider session reuse (claude only) — Stage 2 ── // One provider-native session id per ink session, reused across turns AND // across processes. Seeded on the first backend spawn, resumed thereafter, - // and reset at the ink-owned compaction boundary. Recovered from the - // reattached transcript below so a fresh process (e.g. the next Myra - // heartbeat, which reattaches the same pcp session) RESUMES the same native - // session — the jsonl accumulates one coherent thread instead of fragmenting - // into a new file per message. ink owns compaction; the provider never runs - // its own. - const canReuseBackendSession = runtime.backend === 'claude'; + // and reset at every ink-owned context-boundary change (compaction, trim, + // eviction). Recovered from the reattached transcript below so a fresh + // process (e.g. the next Myra heartbeat, which reattaches the same pcp + // session) RESUMES the same native session — the jsonl accumulates one + // coherent thread instead of fragmenting into a new file per message. ink + // owns compaction; the provider never runs its own. let activeBackendSessionId: string | undefined; + // The backend that owns activeBackendSessionId. A Claude session UUID is + // meaningless to codex/gemini, so a mid-session /backend switch must + // invalidate it (see the per-turn check in runUserTurn). Whether reuse is + // active is computed per-turn against the current backend, not captured here. + let activeBackendSessionBackend: string | undefined; let historyHydration: HistoryHydrationResult | null = null; if (attachedToExistingSession && existingTranscript) { const hydrated = hydrateLedgerFromTranscript(ledger, existingTranscript); // Resume the provider session the prior process left live (delta only), - // unless a compaction rolled it — then the next turn seeds a fresh one. - if (canReuseBackendSession) { + // unless a compaction/eviction rolled it — then the next turn seeds fresh. + if (runtime.backend === 'claude') { activeBackendSessionId = findLastBackendSessionId(existingTranscript); + if (activeBackendSessionId) activeBackendSessionBackend = 'claude'; } // Continue the event-id sequence from where the file left off seedTranscriptEidCounter(existingTranscript, hydrated.maxEid); @@ -3251,6 +3262,14 @@ export async function runChat(options: ChatOptions): Promise { if (sessionEvictedEntries.length > EVICTED_DISPLAY_MAX) { sessionEvictedEntries.splice(0, sessionEvictedEntries.length - EVICTED_DISPLAY_MAX); } + // A context-boundary mutation (SB evict_context, user /evict, system trim — + // all route through this single writer) just removed entries from ink's + // window. Roll the provider session so the next turn re-seeds from the + // post-eviction ledger; otherwise a resumed native session would still hold + // the evicted content. findLastBackendSessionId clears cross-process + // recovery on the matching markers. + activeBackendSessionId = undefined; + activeBackendSessionBackend = undefined; }; const trimContextToPercent = async ( @@ -3398,8 +3417,10 @@ export async function runChat(options: ChatOptions): Promise { // ink just rolled the ledger — roll the provider session too so the next // turn seeds a fresh native session with the summary (we compact before // the provider ever would). No-op when nothing was compacted: the early - // returns above never reach this block. - if (canReuseBackendSession) activeBackendSessionId = undefined; + // returns above never reach this block. Unconditional: for non-claude + // these are already undefined. + activeBackendSessionId = undefined; + activeBackendSessionBackend = undefined; } }; @@ -3939,14 +3960,25 @@ export async function runChat(options: ChatOptions): Promise { // resume the same session. This collapses the whole conversation into ONE // coherent Claude jsonl and stops re-piping the transcript window on every // round-trip. Stateless backends (codex/gemini) always get the full - // envelope. `canReuseBackendSession`/`activeBackendSessionId` are - // session-scoped (declared above) so reuse spans turns and resets at the - // compaction boundary. + // envelope. + // + // `canReuseBackendSession` is computed PER-TURN against the current backend + // so a mid-session /backend switch is honored (not captured once at + // startup). A live session owned by a different backend is invalidated + // first: /backend claude→codex (a Claude UUID is meaningless to + // codex/gemini and would wrongly send delta-only), or claude→other→claude + // (the old native session is missing the intervening turns). Both reseed. + const canReuseBackendSession = runtime.backend === 'claude'; + if (activeBackendSessionId !== undefined && activeBackendSessionBackend !== runtime.backend) { + activeBackendSessionId = undefined; + activeBackendSessionBackend = undefined; + } const resumeProviderSession = canReuseBackendSession && activeBackendSessionId !== undefined; let seedProviderSessionId: string | undefined; if (canReuseBackendSession && !resumeProviderSession) { seedProviderSessionId = randomUUID(); activeBackendSessionId = seedProviderSessionId; + activeBackendSessionBackend = runtime.backend; // Persist the seed so a later process (next heartbeat / reattach) recovers // and RESUMES this native session instead of fragmenting into a new jsonl. appendTranscript(runtime.transcriptPath, { @@ -4086,6 +4118,7 @@ export async function runChat(options: ChatOptions): Promise { // Mirrors ClaudeRunner/InkRunner's resume-not-found recovery. const reseedId = randomUUID(); activeBackendSessionId = reseedId; + activeBackendSessionBackend = runtime.backend; appendTranscript(runtime.transcriptPath, { type: 'backend_session', id: reseedId }); printEvent( chalk.yellow(' ⛁ provider session not found on resume — re-seeding a fresh native session') From 1dc1e9f500e5d32f2203f9164a64a7ebe7c4a2e1 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Thu, 30 Jul 2026 16:33:07 +0900 Subject: [PATCH 4/4] fix: roll provider session on any envelope-shape drift, not just backend (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Lumen's 3rd P1 on #446: the resume delta only carries recall + raw, so other envelope-shaping runtime changes left the resumed Claude session stale — /tool-routing, /skill-use, /skill-clear, /refresh, /model, and profile changes. Concrete break: seed with backend routing, run /tool-routing local; the next turn resumed a native session that still lacked ink-tool instructions while the spawn disabled native tools, stranding tool use. Rather than scatter rolls across a dozen mutation sites (fragile — the exact 'other mutations' critique), generalize: envelopeShapeKey() hashes everything buildPromptEnvelope renders that doesn't change per turn (backend, model, tool mode/routing, strict flag, skills, thread key, identity context). runUserTurn invalidates + reseeds when that shape drifts since the session was seeded. This subsumes the backend-ownership check (backend is part of the shape) and cannot miss a future mutation site. Cross-process safe: recovery adopts the shape baseline lazily on the first turn (after all startup mutations), so a reattached Myra heartbeat resumes rather than spuriously reseeding on a startup-timing difference. Tests: envelopeShapeKey direct tests (changes on every shaping field, stable otherwise) + shape-drift invalidation incl. the /tool-routing case and recovery-adopt (27 total in the file). Re-verified 2B cross-process reuse still works e2e (recall across processes, no new jsonl). Type-check, build, full CLI suite green (974 passed/4 skipped). Co-Authored-By: Wren --- .../src/commands/chat.session-reuse.test.ts | 136 +++++++++++++----- packages/cli/src/commands/chat.ts | 83 ++++++++--- 2 files changed, 168 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/commands/chat.session-reuse.test.ts b/packages/cli/src/commands/chat.session-reuse.test.ts index b49c6e30..863ff110 100644 --- a/packages/cli/src/commands/chat.session-reuse.test.ts +++ b/packages/cli/src/commands/chat.session-reuse.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { isResumeFailedNoSession, findLastBackendSessionId } from './chat.js'; +import { isResumeFailedNoSession, findLastBackendSessionId, envelopeShapeKey } from './chat.js'; /** * Stage 2A — across-turn provider session reuse. @@ -242,77 +242,145 @@ describe('findLastBackendSessionId (cross-process recovery)', () => { }); }); -// Mirrors runUserTurn's backend-ownership check: a live provider session is -// invalidated when the current backend differs from the one that seeded it, so -// a /backend switch never resumes a Claude UUID under codex/gemini and never -// skips intervening turns on claude→other→claude. -function decideWithBackend( - currentBackend: string, +// Mirrors runUserTurn's envelope-shape check: a live provider session is +// invalidated when the current envelope shape differs from the one it was +// seeded with. A /backend switch, /tool-routing, /skill-use/clear, /model, +// /refresh, or profile change all shift the shape, so a resumed native session +// is never left stale. `canReuse` (claude-only) gates seeding independently. +function decideWithShape( + canReuse: boolean, + currentShape: string, activeId: string | undefined, - activeOwner: string | undefined, + activeShape: string | undefined, mintId: () => string ): { invalidated: boolean; resume: boolean; seedId: string | undefined; nextActiveId: string | undefined; - nextOwner: string | undefined; + nextShape: string | undefined; } { let id = activeId; - let owner = activeOwner; + let shape = activeShape; let invalidated = false; - if (id !== undefined && owner !== currentBackend) { - id = undefined; - owner = undefined; - invalidated = true; + if (id !== undefined) { + if (shape === undefined) { + shape = currentShape; // recovered from a prior process — adopt baseline + } else if (shape !== currentShape) { + id = undefined; + shape = undefined; + invalidated = true; + } } - const canReuse = currentBackend === 'claude'; const resume = canReuse && id !== undefined; let seedId: string | undefined; if (canReuse && !resume) { seedId = mintId(); id = seedId; - owner = currentBackend; + shape = currentShape; } - return { invalidated, resume, seedId, nextActiveId: id, nextOwner: owner }; + return { invalidated, resume, seedId, nextActiveId: id, nextShape: shape }; } -describe('provider session backend-ownership (mid-session /backend switch)', () => { +describe('provider session envelope-shape invalidation', () => { const minter = () => { const ids = ['S1', 'S2', 'S3']; let i = 0; return () => ids[i++]!; }; - it('claude→codex invalidates the Claude session and does not resume it', () => { + it('/tool-routing drift (same claude backend) reseeds — Lumen P1 concrete case', () => { const mint = minter(); - // Seed on claude. - let d = decideWithBackend('claude', undefined, undefined, mint); + // Seed with the backend-routing envelope shape. + let d = decideWithShape(true, 'shape:backend-routing', undefined, undefined, mint); expect(d.seedId).toBe('S1'); - // Switch to codex: the Claude UUID is invalidated, codex does not reuse. - d = decideWithBackend('codex', d.nextActiveId, d.nextOwner, mint); + // /tool-routing local changes the rendered tool instructions → new shape. + d = decideWithShape(true, 'shape:local-routing', d.nextActiveId, d.nextShape, mint); expect(d.invalidated).toBe(true); expect(d.resume).toBe(false); - expect(d.seedId).toBeUndefined(); - expect(d.nextActiveId).toBeUndefined(); + expect(d.seedId).toBe('S2'); // fresh session carries the new envelope + }); + + it('/skill-use drift reseeds so the new skill instructions are seen', () => { + const mint = minter(); + let d = decideWithShape(true, 'shape:noskills', undefined, undefined, mint); // S1 + d = decideWithShape(true, 'shape:skill-a', d.nextActiveId, d.nextShape, mint); + expect(d.invalidated).toBe(true); + expect(d.seedId).toBe('S2'); + }); + + it('/backend claude→codex invalidates and does not resume (codex cannot reuse)', () => { + const mint = minter(); + const d1 = decideWithShape(true, 'shape:claude', undefined, undefined, mint); // S1 + const d2 = decideWithShape(false, 'shape:codex', d1.nextActiveId, d1.nextShape, mint); + expect(d2.invalidated).toBe(true); + expect(d2.resume).toBe(false); + expect(d2.seedId).toBeUndefined(); + expect(d2.nextActiveId).toBeUndefined(); }); it('claude→codex→claude reseeds fresh (never resumes the pre-switch session)', () => { const mint = minter(); - let d = decideWithBackend('claude', undefined, undefined, mint); // S1 - const onCodex = decideWithBackend('codex', d.nextActiveId, d.nextOwner, mint); - d = decideWithBackend('claude', onCodex.nextActiveId, onCodex.nextOwner, mint); - expect(d.resume).toBe(false); - expect(d.seedId).toBe('S2'); // fresh, not S1 — intervening codex turns aren't in S1 - expect(d.nextOwner).toBe('claude'); + const d1 = decideWithShape(true, 'shape:claude', undefined, undefined, mint); // S1 + const onCodex = decideWithShape(false, 'shape:codex', d1.nextActiveId, d1.nextShape, mint); + const d3 = decideWithShape(true, 'shape:claude', onCodex.nextActiveId, onCodex.nextShape, mint); + expect(d3.resume).toBe(false); + expect(d3.seedId).toBe('S2'); // not S1 — intervening codex turns aren't in it }); - it('claude→claude resumes the same session (no spurious invalidation)', () => { + it('stable shape resumes the same session (no spurious invalidation)', () => { const mint = minter(); - const d1 = decideWithBackend('claude', undefined, undefined, mint); // S1 - const d2 = decideWithBackend('claude', d1.nextActiveId, d1.nextOwner, mint); + const d1 = decideWithShape(true, 'shape:claude', undefined, undefined, mint); // S1 + const d2 = decideWithShape(true, 'shape:claude', d1.nextActiveId, d1.nextShape, mint); expect(d2.invalidated).toBe(false); expect(d2.resume).toBe(true); expect(d2.nextActiveId).toBe('S1'); }); + + it('a recovered session (no baseline shape yet) adopts the shape and resumes', () => { + // Cross-process reattach: the id is recovered from the transcript but the + // shape baseline is adopted lazily on this first turn — so it resumes + // (Myra heartbeat continuity) instead of spuriously reseeding. + const mint = minter(); + const d = decideWithShape(true, 'shape:claude', 'recovered-id', undefined, mint); + expect(d.invalidated).toBe(false); + expect(d.resume).toBe(true); + expect(d.nextActiveId).toBe('recovered-id'); + expect(d.nextShape).toBe('shape:claude'); + }); +}); + +describe('envelopeShapeKey (real function)', () => { + type RT = Parameters[0]; + const base = { + backend: 'claude', + model: 'claude-sonnet-5', + toolMode: 'backend', + toolRouting: 'local', + strictTools: false, + threadKey: undefined, + activeSkills: [] as Array<{ name: string }>, + bootstrapContext: 'ctx', + }; + const key = (over: Partial): string => + envelopeShapeKey({ ...base, ...over } as unknown as RT); + + it('is stable for an identical shape', () => { + expect(key({})).toBe(key({})); + }); + + it('changes when tool routing changes (the concrete stale-tools case)', () => { + expect(key({ toolRouting: 'backend' })).not.toBe(key({ toolRouting: 'local' })); + }); + + it('changes on any envelope-shaping field, so no mutation site can be missed', () => { + const b = key({}); + expect(key({ backend: 'codex' })).not.toBe(b); + expect(key({ model: 'other-model' })).not.toBe(b); + expect(key({ toolMode: 'off' })).not.toBe(b); + expect(key({ strictTools: true })).not.toBe(b); + expect(key({ activeSkills: [{ name: 'skill-a' }] })).not.toBe(b); + expect(key({ threadKey: 'pr:1' })).not.toBe(b); + expect(key({ bootstrapContext: 'different identity context' })).not.toBe(b); + }); }); diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index d1ffeb2c..a07d0f50 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -2299,6 +2299,39 @@ function buildPromptEnvelope( .join('\n'); } +/** + * A stable signature of everything buildPromptEnvelope renders that does NOT + * change per turn — the static "shape" a seeded provider session already holds: + * system framing, tool instructions (from tool mode/routing), strict flag, + * skills, thread key, and identity context. Excludes the transcript/recall/raw, + * which ARE the intended per-turn delta. When this drifts mid-session — /backend, + * /model, /tool-routing, /skill-use, /skill-clear, /refresh, profile changes — + * the resumed native session would be stale (e.g. seeded with backend + * tool-routing, then /tool-routing local leaves it without ink-tool + * instructions), so runUserTurn invalidates and reseeds. Subsumes the backend + * check (backend is part of the shape). Hashed so the stored key stays small. + * Keep this in sync with buildPromptEnvelope's static (non-transcript) fields. + */ +export function envelopeShapeKey(runtime: ChatRuntime): string { + const shape = [ + runtime.backend, + runtime.model ?? '', + runtime.toolMode, + runtime.toolRouting, + runtime.strictTools ? '1' : '0', + runtime.threadKey ?? '', + runtime.activeSkills.map((s) => s.name).join(','), + runtime.bootstrapContext ?? '', + ].join(''); + // djb2 — cheap, kept in int32 each step; collision-resistant enough to detect + // config drift (we only need change-detection, not cryptographic strength). + let hash = 5381; + for (let i = 0; i < shape.length; i++) { + hash = ((hash << 5) + hash + shape.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +} + export async function runChat(options: ChatOptions): Promise { const debugFile = initSbDebug({ enabled: options.sbDebug, @@ -2869,11 +2902,13 @@ export async function runChat(options: ChatOptions): Promise { // coherent thread instead of fragmenting into a new file per message. ink // owns compaction; the provider never runs its own. let activeBackendSessionId: string | undefined; - // The backend that owns activeBackendSessionId. A Claude session UUID is - // meaningless to codex/gemini, so a mid-session /backend switch must - // invalidate it (see the per-turn check in runUserTurn). Whether reuse is - // active is computed per-turn against the current backend, not captured here. - let activeBackendSessionBackend: string | undefined; + // Signature of the envelope's static shape at the time the session was seeded + // (backend, model, tool mode/routing, strict flag, skills, thread key, + // identity context). When it drifts mid-session — /backend, /model, + // /tool-routing, /skill-use, /skill-clear, /refresh, profile changes — the + // resumed native session would be stale, so runUserTurn invalidates and + // reseeds. Subsumes the backend check (backend is part of the shape). + let activeBackendSessionShape: string | undefined; let historyHydration: HistoryHydrationResult | null = null; if (attachedToExistingSession && existingTranscript) { @@ -2881,8 +2916,10 @@ export async function runChat(options: ChatOptions): Promise { // Resume the provider session the prior process left live (delta only), // unless a compaction/eviction rolled it — then the next turn seeds fresh. if (runtime.backend === 'claude') { + // Recover the id only; the shape baseline is adopted on the first turn + // below, AFTER all startup mutations, so a recovered session resumes + // rather than spuriously reseeding on a startup-timing shape difference. activeBackendSessionId = findLastBackendSessionId(existingTranscript); - if (activeBackendSessionId) activeBackendSessionBackend = 'claude'; } // Continue the event-id sequence from where the file left off seedTranscriptEidCounter(existingTranscript, hydrated.maxEid); @@ -3269,7 +3306,7 @@ export async function runChat(options: ChatOptions): Promise { // the evicted content. findLastBackendSessionId clears cross-process // recovery on the matching markers. activeBackendSessionId = undefined; - activeBackendSessionBackend = undefined; + activeBackendSessionShape = undefined; }; const trimContextToPercent = async ( @@ -3420,7 +3457,7 @@ export async function runChat(options: ChatOptions): Promise { // returns above never reach this block. Unconditional: for non-claude // these are already undefined. activeBackendSessionId = undefined; - activeBackendSessionBackend = undefined; + activeBackendSessionShape = undefined; } }; @@ -3964,21 +4001,33 @@ export async function runChat(options: ChatOptions): Promise { // // `canReuseBackendSession` is computed PER-TURN against the current backend // so a mid-session /backend switch is honored (not captured once at - // startup). A live session owned by a different backend is invalidated - // first: /backend claude→codex (a Claude UUID is meaningless to - // codex/gemini and would wrongly send delta-only), or claude→other→claude - // (the old native session is missing the intervening turns). Both reseed. + // startup). And a live session is invalidated when the envelope's static + // SHAPE has drifted since it was seeded — /backend, /model, /tool-routing, + // /skill-use, /skill-clear, /refresh, profile changes. Otherwise the resumed + // native session would be stale (e.g. seeded with backend tool-routing, then + // /tool-routing local leaves it without ink-tool instructions while native + // tools are disabled). On drift we reseed fresh with the new envelope. const canReuseBackendSession = runtime.backend === 'claude'; - if (activeBackendSessionId !== undefined && activeBackendSessionBackend !== runtime.backend) { - activeBackendSessionId = undefined; - activeBackendSessionBackend = undefined; + const currentEnvelopeShape = envelopeShapeKey(runtime); + if (activeBackendSessionId !== undefined) { + if (activeBackendSessionShape === undefined) { + // Recovered from a prior process — adopt this turn's shape as the + // baseline (no invalidation). Cross-process bootstrap drift is + // tolerated; only in-process drift from here triggers a reseed. + activeBackendSessionShape = currentEnvelopeShape; + } else if (activeBackendSessionShape !== currentEnvelopeShape) { + // In-process envelope drift — the resumed native session would be + // stale, so invalidate and reseed fresh with the new envelope. + activeBackendSessionId = undefined; + activeBackendSessionShape = undefined; + } } const resumeProviderSession = canReuseBackendSession && activeBackendSessionId !== undefined; let seedProviderSessionId: string | undefined; if (canReuseBackendSession && !resumeProviderSession) { seedProviderSessionId = randomUUID(); activeBackendSessionId = seedProviderSessionId; - activeBackendSessionBackend = runtime.backend; + activeBackendSessionShape = currentEnvelopeShape; // Persist the seed so a later process (next heartbeat / reattach) recovers // and RESUMES this native session instead of fragmenting into a new jsonl. appendTranscript(runtime.transcriptPath, { @@ -4118,7 +4167,7 @@ export async function runChat(options: ChatOptions): Promise { // Mirrors ClaudeRunner/InkRunner's resume-not-found recovery. const reseedId = randomUUID(); activeBackendSessionId = reseedId; - activeBackendSessionBackend = runtime.backend; + activeBackendSessionShape = currentEnvelopeShape; appendTranscript(runtime.transcriptPath, { type: 'backend_session', id: reseedId }); printEvent( chalk.yellow(' ⛁ provider session not found on resume — re-seeding a fresh native session')