Просмотр исходного кода

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

Colby McHenry 1 месяц назад
Родитель
Сommit
4e94860f8f
1 измененных файлов с 109 добавлено и 0 удалено
  1. 109 0
      docs/design/explore-session-dedup.md

+ 109 - 0
docs/design/explore-session-dedup.md

@@ -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.