Skip to content

feat: Stage 2 — across-turn + cross-process provider session reuse (by Wren) - #446

Merged
conoremclaughlin merged 4 commits into
mainfrom
wren/feat/backend-session-reuse-across-turn
Jul 30, 2026
Merged

feat: Stage 2 — across-turn + cross-process provider session reuse (by Wren)#446
conoremclaughlin merged 4 commits into
mainfrom
wren/feat/backend-session-reuse-across-turn

Conversation

@conoremclaughlin

Copy link
Copy Markdown
Owner

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. maybeCompactContext now 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-interactive process (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 a backend_session transcript 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. A compaction marker 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_session marker + findLastBackendSessionId recovery, 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

  • 2A e2e: seed→resume→delta recalls a planted codeword (WREN-DELTA-PROOF); two interactive turns → ONE jsonl.
  • 2B e2e (the Myra scenario): 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"]).
  • Type-check, build, and full CLI unit suite green (962 passed / 4 skipped).

Review notes

  1. 2B is self-contained in chat.ts via 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?
  2. The compaction marker clearing the recovered candidate is the subtle correctness point — confirm the recovery logic matches the roll-on-compaction intent.
  3. Non-claude backends (codex/gemini) fall through to the full-envelope-per-spawn path unchanged.

🤖 Generated with Claude Code

conoremclaughlin and others added 2 commits July 30, 2026 15:37
…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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...HEAD
  • yarn workspace @inklabs/cli vitest run src/commands/chat.session-reuse.test.ts (15 passed)
  • yarn workspace @inklabs/cli type-check
  • yarn workspace @inklabs/cli build
  • yarn workspace @inklabs/cli test (962 passed / 4 skipped)

Comment thread packages/cli/src/commands/chat.ts Outdated
Comment thread packages/cli/src/commands/chat.ts Outdated
… 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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...HEAD
  • yarn workspace @inklabs/cli vitest run src/commands/chat.session-reuse.test.ts (21 passed)
  • yarn workspace @inklabs/cli type-check
  • yarn workspace @inklabs/cli build
  • yarn workspace @inklabs/cli test (968 passed / 4 skipped)

Comment thread packages/cli/src/commands/chat.ts
…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 conoremclaughlin left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...HEAD
  • yarn workspace @inklabs/cli vitest run src/commands/chat.session-reuse.test.ts (27 passed)
  • yarn workspace @inklabs/cli type-check
  • yarn workspace @inklabs/cli build
  • yarn workspace @inklabs/cli test (974 passed / 4 skipped)

@conoremclaughlin
conoremclaughlin merged commit d2ed85a into main Jul 30, 2026
3 of 4 checks passed
conoremclaughlin added a commit that referenced this pull request Jul 30, 2026
…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)
conoremclaughlin added a commit that referenced this pull request Aug 3, 2026
… 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)
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.

1 participant