docs: the session-state layer — its four constraints and which way to be wrong (CG-17)

This commit is contained in:
Colby McHenry
2026-08-05 13:24:06 -05:00
parent fc31b1e2bf
commit 4e94860f8f
+109
View File
@@ -0,0 +1,109 @@
# Cross-call explore session state
`codegraph_explore` answers every call as if it were the first one. It has no idea what it
already sent this session, so a 4th call happily re-serves the spine the 1st call already
delivered (the #1500 report: 4 calls on a 2-call tier budget), and the tier's call budget
can only be *asked* for in prose the agent ignores.
This document covers the state layer that fixes the "no idea" part — `src/mcp/explore-session-state.ts`
(CG-17). It changes no response. Its consumers are cross-call source dedup (CG-18) and
budget decay past the tier budget (CG-19).
## What is recorded
One `ExploreSessionState` per MCP session. Inside it, per **resolved project root**:
- `callCount` / `responseBytes` — every explore call served this session for that project;
- `calls[]` — the recent ones in detail: query, per-file emitted **line ranges**, source
bytes, response bytes, and the call's 1-based session index.
Ranges come from the render loop itself — `buildSection` returns the spans it slices
alongside the text, and the whole-file / focused / skeleton paths report theirs at the
point they push source into the response. A separate function mirroring the window and
padding rules would drift, and drift here is not symmetric: see *Which way to be wrong*.
Only files that **survive the final hard-ceiling truncation** are recorded. A section the
ceiling dropped was never delivered.
## Four constraints, and what each one rules out
| Constraint | Why | What it rules out |
|---|---|---|
| Per session, never persisted | A new agent has seen nothing | A disk cache keyed by project |
| Per **resolved** project root | One session can query several projects by `projectPath` | Keying on the path the agent typed — `/repo` and `/repo/internal` are one project |
| Bounded memory | Sessions can run for hours | Unbounded `calls[]` growth |
| Daemon-safe | One daemon shares ONE `ToolHandler` and a pool of worker threads across every connected client | State on the handler, in a worker, or in a module-level singleton |
The daemon constraint is the sharp one. State kept on the shared `ToolHandler` would blend
two agents' histories, and a dedup built on that would withhold source from an agent that
never saw it — which costs a Read, the exact failure this area exists to prevent. So the
state lives on `MCPSession`, and the plumbing is:
```
MCPSession (owns the state)
└─ ToolHandler.execute(tool, args, sessionState)
├─ down: session view attached to args (survives structured clone → worker)
└─ up: emission attached to the result (survives structured clone ← worker)
└─ recorded on the MAIN thread, then DELETED from the result
```
Both legs travel as plain properties (`_cgExploreSession`, `_cgExploreEmission`) because
either may cross a worker boundary, where a closure or a handler field could not follow.
The emission is stripped in `execute` **unconditionally** — including for callers that
track nothing, like the CLI — so the agent-facing response is byte-identical. A view a
client spells itself is discarded, not trusted: it decides what a later call may withhold.
## The bounds
`EXPLORE_SESSION_LIMITS`: 4 projects (LRU), 8 retained calls per project, 24 files per
call, 24 ranges per file, 4 calls in the view handed to a call.
Every bound caps **detail**. `callCount` and `responseBytes` keep counting past eviction —
decay (CG-19) reads the count, and a bound that reset it would make decay reset itself
every 8 calls.
## Which way to be wrong
Where a bound forces a choice, the record keeps **fewer** ranges than were emitted, never
more:
- under-report → a later call re-serves something the agent already has. Wasteful.
- over-report → a later call withholds source the agent never saw. The agent Reads the
file, and one Read costs more than every byte the dedup saved.
So `coalesceRanges` drops the smallest spans when it hits the cap (and flags
`rangesTruncated`), invalid spans are discarded rather than clamped, and truncated file
sections are never recorded.
## Inspecting it
The CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`, see
[explore-budget-allocation.md](./explore-budget-allocation.md)) carries a `session` block
on every report:
```
session call #2 for this project · 1 prior call · 18,204 chars already served
already served internal/usecase/payroll_cycle.go · 4,928 chars · L1-159
```
`callIndex` is this call's position in the session; `priorFiles` unions the ranges already
served per file, most-recent call first. The block is **absent** — not zeroed — when the
caller tracks no state, which is how "untracked" and "first call of a tracked session" stay
distinguishable.
## Coverage
`__tests__/explore-session-state.test.ts`, in three layers: the container (keying, monotonic
index past eviction, every bound), the handler seam (a real explore against a real index
records real ranges; the response is byte-identical to an untracked one; two states on one
handler stay separate), and the session seam (two `MCPSession`s on one engine get their own
state, and each call carries its own session's).
Two things vitest cannot cover, verified by hand against `dist/`:
- **the worker path** — with a `QueryPool` attached, the emission survives the structured
clone back from the worker, records on the main thread, and is absent from the result;
- **two genuinely different projects in one session** — opening a second index inside vitest
fails on the lazy `require('../index')`. The in-suite substitute reaches one project two
ways (bare, and by a `projectPath` pointing at a subdirectory) and asserts both land in one
bucket; multi-project keying itself is covered at the container level.