feat: Stage 2 — across-turn + cross-process provider session reuse (by Wren) - #446
Conversation
…by Wren) 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 <noreply@anthropic.com>
… (by Wren) 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 <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Thanks Wren — the transcript-marker approach for 2B generally reads right to me, and the compaction marker clearing the recovered candidate matches the roll-on-compaction intent for the auto-compaction path. I did find two correctness gaps in the new session-scoped reuse that I’d treat as request-changes even though GitHub won’t let this account submit a formal REQUEST_CHANGES review on its own PR: backend changes can reuse the wrong native session/delta path, and manual context eviction/trim does not roll the provider session, so ink no longer fully owns the context boundary in those cases.
Local validation passed:
git diff --check origin/main...HEADyarn workspace @inklabs/cli vitest run src/commands/chat.session-reuse.test.ts(15 passed)yarn workspace @inklabs/cli type-checkyarn workspace @inklabs/cli buildyarn workspace @inklabs/cli test(962 passed / 4 skipped)
… mutations (by Wren) 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 <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
Re-reviewed head 2f4ba50e. The two original P1s are fixed: backend ownership invalidation now prevents Claude UUID reuse across /backend switches, and context_evict/context_trim now roll/clear provider-session recovery. I resolved those two outdated review threads.
I found one remaining stale-envelope issue in the same delta-only resume path: prompt-envelope mutations such as /tool-routing, /refresh, and skill/tool policy changes still do not roll the Claude provider session, so the resumed provider keeps old instructions even though ink has changed runtime state.
Validation is green locally:
git diff --check origin/main...HEADyarn workspace @inklabs/cli vitest run src/commands/chat.session-reuse.test.ts(21 passed)yarn workspace @inklabs/cli type-checkyarn workspace @inklabs/cli buildyarn workspace @inklabs/cli test(968 passed / 4 skipped)
…end (by Wren) 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 <noreply@anthropic.com>
conoremclaughlin
left a comment
There was a problem hiding this comment.
LGTM — no remaining blocking findings at 1dc1e9f5.
The new envelopeShapeKey(runtime) check closes the stale-envelope P1 for the in-process cases I was worried about: /tool-routing, /backend, /model, /refresh, /skill-use//skill-clear, and profile/tool-mode drift all reseed before relying on delta-only Claude resume. The concrete backend-routing → local-routing case is now covered by tests, and the prior review thread is resolved.
The original backend-switch and context-boundary findings remain fixed as well: context eviction/trim roll live state and clear cross-process recovery, and resume-not-found still reseeds with a full envelope.
Validation passed locally:
git diff --check origin/main...HEADyarn workspace @inklabs/cli vitest run src/commands/chat.session-reuse.test.ts(27 passed)yarn workspace @inklabs/cli type-checkyarn workspace @inklabs/cli buildyarn workspace @inklabs/cli test(974 passed / 4 skipped)
…by Wren) (#448) ## Why Stage 2 (#446) had strong **unit** coverage but its integration/live tiers were validated **manually** (shell runs against real Claude). This codifies the highest-value manual proof — the cross-process/Myra codepath — into a committed test, closing that gap. ## What it asserts Two SEPARATE `ink chat --non-interactive` processes (like two heartbeats) sharing one PCP session id, against a real server + real Claude: - **exactly ONE** native jsonl created across both invocations — process 2 **resumes**, it does not fragment into a new file (this is the exact opacity the feature fixes); - the ink transcript's `backend_session` marker matches that jsonl id; - the resumed session recalls a codeword planted in process 1, though process 2 sent only the delta (cross-process continuity). The first two are **deterministic** (independent of LLM phrasing); the recall is a substring check. ## How it runs - Opt-in: `INK_LIVE_RUN_CLAUDE=1 yarn test:live` (costs Claude API). - Gated on server reachability; **excluded from the default `yarn test`** (via the existing `**/*.live.test.ts` exclude). - realpath's the fixture cwd so it matches how Claude keys its project dir on macOS. - Verified locally: **1 passed** (~12s). ## Coverage honesty Covers **2B (cross-process)**. **2A** (across-turn interactive) and the **envelope-shape-drift reseed** stay unit-covered — the interactive multi-turn path resists automation (readline doesn't consume piped stdin past the first line). Open to ideas if you know a clean way to drive interactive multi-turn. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
… Wren) (#449) Closes the keystone of the provider-session-reuse task group ([03ad31d0](#), task `17d212ff`): **ink owns compaction, not the provider** — plus live validation on the Myra path (task `a61d7320`). ## The bug this fixes `resolveBackendTokenWindow()` was a stub returning **1M tokens for every model**. ink derives its working budget (and therefore its compaction threshold) from that window. So a model with a smaller real window — e.g. a 200K Claude — had its compaction point computed off a phantom 1M window, landing it **above** where the provider runs its own auto-compaction. The provider compacted first. That is exactly the failure the keystone exists to prevent (opaque fragmented jsonls, provider-owned summaries). ## The fix (kept simple) New module `packages/cli/src/repl/context-limits.ts` owns: - **A conservative per-model context-window table** (longest-prefix match). Under-estimating a window only makes ink compact a little early (harmless); over-estimating lets the provider win (the bug) — so every value rounds **down**. - **`PROVIDER_HEADROOM_PCT` (0.85)** — ink’s *entire* working budget stays at or below the fraction of the window where the provider might begin auto-compacting. Combined with the existing `0.8` in-budget threshold, ink compacts by **~0.68 × window** worst-case — comfortably ahead of the provider. `chat.ts` imports the two functions under their existing names, so every call site (runtime construction + `/model` and `/backend` switch handlers) picks up per-model resolution with **zero call-site churn**. The compaction boundary already rolls the native provider session (Stage 2, #446), so correct budgets now make ink win that race for every model — ledger-compaction and native-session-reset happen together. ## Tests — unit + live **Unit** (`context-limits.test.ts`, 15 tests): table resolution, longest-prefix (`gpt-5` beats `gpt-`), case/whitespace, per-backend defaults, budget headroom + cap, and the **keystone safety invariant** — ink’s compaction point sits strictly below the provider trigger for every supported model. **Live** (`ink-owned-compaction.live.test.ts`): forces the boundary on the non-interactive (Myra heartbeat) path — pre-seed a transcript past a tiny `--max-context-tokens`, run one real turn against a live server + Claude, assert ink wrote a `compaction` event (`removedCount > 0`), rolled the native session (fresh `backend_session` marker + native jsonl), and exited 0. Deterministic (independent of LLM phrasing); opt-in via `INK_LIVE_RUN_CLAUDE`. Verified live this session: `removedCount 16, removedTokens 9802`, fresh native session seeded post-compaction, exit 0. Full CLI unit suite (989) green; both live tests pass together. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
What & why
Stage 2 of provider session reuse (builds on #445, Stage 1). Goal (Conor's framing): reuse a single provider-native session id per ink session so we (a) get one coherent Claude jsonl to debug instead of a new file per turn, (b) can read the full thread in the provider TUI, and (c) let ink own compaction instead of the provider. Delivers both halves — interactive and server — and stays self-contained in
chat.ts(no server/InkRunner changes).2A — across-turn reuse + ink-owned compaction reset (interactive)
Lifts Stage 1's per-turn Claude seed to session scope. First backend spawn seeds one provider session (
--session-id+ full envelope); every later turn resumes it (--resume+ delta = new user message + any passive-recall). The whole interactive conversation becomes ONE native jsonl.maybeCompactContextnow 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.2B — cross-process reuse (server / Myra heartbeats)
Each Myra heartbeat is a separate
ink chat --non-interactiveprocess (one message), so 2A alone doesn't help it — every heartbeat seeded a fresh session = new jsonl = the opacity Conor hit. Fix: persist the live provider session id as abackend_sessiontranscript marker on seed, and recover it on reattach (findLastBackendSessionId). The ink transcript is keyed by pcp session id and reattached across processes, so the next heartbeat resumes the same native session and the jsonl accumulates one coherent thread. Acompactionmarker clears the recovered candidate (post-compaction processes start fresh with the summary — never drag the pre-compaction window back). Resume-not-found re-seeds mid-turn (mirrors ClaudeRunner/InkRunner) so a stale recovered id still produces output.Files
packages/cli/src/commands/chat.ts— session-scoped seed/resume, compaction reset,backend_sessionmarker +findLastBackendSessionIdrecovery, mid-turn re-seed,isResumeFailedNoSession.packages/cli/src/commands/chat.session-reuse.test.ts— 15 unit tests (seed/resume/compaction-reset decision + recovery incl. compaction-clears-candidate + resume-not-found detector).Validation
WREN-DELTA-PROOF); two interactive turns → ONE jsonl.ink chat --non-interactiveinvocations 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"]).Review notes
chat.tsvia a transcript marker (vs. threading the id through InkRunner + the server). Chosen for simplicity/maintainability and zero server changes — does that tradeoff read right to you?compactionmarker clearing the recovered candidate is the subtle correctness point — confirm the recovery logic matches the roll-on-compaction intent.🤖 Generated with Claude Code