浏览代码

test(perf): centralize lifecycle benchmarks

imccyu 1 周之前
父节点
当前提交
47a6fa0f28

+ 2 - 2
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
-2026-09-04-session-open-performance-gate.md: fb22f4cfbc0dad9e68b5219d7fc4aba6a47398f4
-2026-09-04-session-open-performance-gate.zh.md: bc574f9e4963933b1dae76a165ffb00a31a87ae3
+2026-09-04-session-open-performance-gate.md: 701d1a1fe275659b36e7999bc847602ac329a2c8
+2026-09-04-session-open-performance-gate.zh.md: 0ff80764b6a78089664cd5853fe0cc1ad65e7ccd

+ 58 - 13
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md

@@ -6,33 +6,78 @@ English | [中文](2026-09-04-session-open-performance-gate.zh.md)
 
 ## Problem
 
-The Session format v2 rollout changed two paths whose cost scales with model output: the JSONL backend migrates and publishes a released-v0 log on its first `open()`, and the Client folds each settled reply's embedded compact stream. Neither path had an executed performance check, so a first open that grew from about 35 ms to about 5 s on a 127,400-event synthetic log (and from about 0.3 s to 26 s on a 575,000-chunk real log, with peak RSS of 2.7 GB and heap exhaustion under a 512 MB limit) and a Client fold that grew linearly with streamed deltas instead of compact records both reached master unnoticed. Unit tests use small logs, the coverage gate measures lines, and the existing `test:web:perf` inventory is a manual diagnostic outside CI.
+The Session format v2 rollout changed two paths whose cost scales with model output: the JSONL backend migrates and publishes a released-v0 log, and the Client folds each settled reply's embedded compact stream. Neither path had an executable performance check, so first open grew from about 35 ms to about 5 s on a 127,400-event synthetic log (and from about 0.3 s to 26 s on a 575,000-chunk real log, with 2.7 GB peak RSS and heap exhaustion under a 512 MB limit), while Client fold grew linearly with streamed deltas instead of compact records; these regressions reached master unnoticed.
+
+Measuring only `SessionPersistence.open()` does not stably describe the result for which a user or Host waits. Work can move among `open()`, `SessionHandle.read()`, Session restoration, and projection, while the first history page and cold Agent resume add separate orchestration above those operations. A single `heapUsed` sample without prior GC also cannot distinguish data still retained by the Session from reclaimable migration temporaries.
 
 ## Decision
 
-Linux pull requests run a required `node 24 / benchmarks` job that executes `pnpm run check:ci:bench` → `pnpm run test:bench` → `vitest.bench.config.ts`, which collects `packages/*/*/tests/**/*.bench.ts` and `*.bench.client.ts` and runs one file at a time. The job runs the benchmark lane alone, on the same runner selector and failover switch as the other required Linux workers, and joins the `all checks passed` verdict.
+Linux pull requests run a required `node 24 / benchmarks` job that executes `pnpm run check:ci:bench` → `pnpm run test:bench` → `vitest.bench.config.ts`. The job runs the benchmark lane alone; Vitest runs one file at a time and only prepares input, starts measurement children, aggregates results, and enforces budgets.
+
+Required performance gates live under top-level `benchmarks/`, grouped by measured user path rather than package ownership. Host files use `*.bench.ts`, Client-face files use `*.bench.client.ts`, and scenario-specific workers and fixtures stay beside their benchmark without a benchmark suffix. Package-local `.perf.ts` files remain non-gating diagnostics; `scripts/` owns orchestration rather than benchmark cases.
+
+The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions.
 
-Every benchmark synthesizes its input in-process from fixed parameters: numbered prompts, counter tokens, fixed timestamps. Recorded Sessions are never used because they carry user content, differ between machines, and drift as fixtures are re-recorded. Each benchmark documents its budget beside the constant that enforces it, and budgets follow three rules: a wall-clock budget sits a small multiple above the intended cost and well below the regression it guards; a memory budget runs the measured path in a child Node process under a fixed `--max-old-space-size`, so an allocation regression fails as an out-of-memory exit regardless of the runner's physical memory; and a scaling assertion compares two sizes of the same workload so a complexity regression fails on any host speed.
+Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and therefore includes migration and successor publication. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published V2 successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches.
 
-The first two gates cover the two regressed paths:
+Each access-kind and endpoint sample runs in a fresh Node child process. Module imports, Host service initialization, and fixture preparation finish before measurement; the measured process performs no extra parse warm-up. Normal-heap mode runs five independent samples, reports every sample plus minimum, median, and maximum, and enforces access-specific fixed budgets against the median. Another child runs the same path under a fixed 128 MB old-space limit and checks only that it completes; extra GC caused by the constrained heap does not enter the normal timing baseline.
 
-| Benchmark | Workload | Gates |
+The lane contains three independent Session-opening benchmarks and retains the Client-fold benchmark:
+
+| Benchmark | Measured path | Timing metrics |
 |---|---|---|
-| `packages/session/session-persistence-jsonl/tests/open-generation.bench.ts` | 200 turns × (500 text + 125 reasoning deltas) = 127,400 released-v0 events, about 2.8 MB, encoded through the frozen v0 codec with packed rows | migrating first `open()` ≤ 4,000 ms under a 128 MB heap; fresh-process open of the published current generation ≤ 500 ms; minimum of three attempts |
-| `packages/client/ui-chat/tests/conversation-fold.bench.client.ts` | 200 replies whose compact streams hold 2,000 text + 500 reasoning deltas each (500,000 deltas in 1,600 records), folded through every Chat Definition by the real `ConversationNodeAssembler` | large fold ≤ 150 ms; large fold ≤ 5× the fold of the same window with 100 deltas per reply |
+| Phase profile | Executes the real persistence open, handle read, Session restore, and projection for both first open and post-upgrade reopen | `openMs`, `readMs`, `sessionRestoreMs`, and `projectionMs` each have a fixed budget; encoding, writes, verification, and publication awaited by migration all belong to first-open `openMs` |
+| First history | Reads each access kind through the Host Session history controller until it produces the first paginated snapshot | Separate first-open and reopen end-to-end budgets; each includes source stat, reading, restoration, projection, pagination, and snapshot construction, while first open additionally includes migration; both exclude Gateway network transport, Client fold, and browser paint |
+| Agent resume | Calls `ctx.agents.resume()` for each access kind until Agent creation, setup, publication, and loop startup finish | Separate first-open and reopen end-to-end budgets; neither path runs after first-history or reuses that benchmark's cache |
+| Client fold | Folds small and large v2 history windows through the real `ConversationNodeAssembler` and every Chat Definition | The large window's absolute time and scaling relative to the small window each have a fixed budget |
+
+The phase profile invokes each layer's production entry point explicitly and does not copy any decode, migration, restore, or projection algorithm. First-history and Agent-resume each run their real higher-level entry point against fresh first-open and reopen roots, so component measurements do not stand in for end-to-end results and one scenario cannot warm another's process or Session cache. The sum of the four phases is diagnostic only; an outer clock independently measures each end-to-end result.
+
+Normal-heap mode performs a fixed pair of explicit garbage collections after Host initialization and before the cold Session is touched, then records starting memory. It stops operation timing before performing the same garbage-collection sequence while the scenario's intended long-lived objects remain explicitly reachable, then records ending memory. The Agent-resume endpoint retains the Agent, Session, complete events, and normal service caches; its `heapUsed` delta is the primary resident-Session memory budget. Every scenario also reports `external`, `arrayBuffers`, post-GC RSS, and `process.resourceUsage().maxRSS`; the 128 MB mode prevents transient allocation peaks from being hidden by endpoint collection. Explicit garbage-collection time is excluded from operation timing.
+
+The performance gate does not duplicate semantic assertions owned by functional tests; it requires only that the target call completes and reaches its measured endpoint. The Client-fold benchmark continues to use the real `ConversationNodeAssembler` and every Chat Definition, and requires both the large window's absolute time and its scaling relative to the small window to remain below fixed budgets.
+
+Budgets use repeated measurements of the final implementation on the target CI runner, with enough margin for runner noise while remaining below the known regression. Pre-stack commit `0d7ea53743e273930a31e9e2b6ca682f21dd4ca5` is the fixed calibration and review reference; CI does not check out or execute the historical repository. Budgets are reviewed source constants and have no environment-variable override.
+
+## Calibration evidence
+
+The comparison is orthogonal by user lifecycle, not by artifact representation. Both implementations receive the same fixed V0 bytes for first open. For reopen, each implementation reads the format it considers current in a fresh process: the pre-stack reference remains on V0, while the V2 implementation reads its published V2 successor. This intentionally compares the same user's later-open experience rather than two codecs over one data structure.
 
-Measured on the reference machine at the commit that introduced the gate, the migration benchmark exhausted the 128 MB heap and the fold benchmark scaled 11× between the small and large windows, so both gates fail on the regressed code and pass once the paths do O(records) work.
+Five-sample medians on the same Node 24 reference machine establish the positive and negative controls:
+
+| Access kind | Implementation | Four-phase total | First history | Agent resume | 128 MB old space |
+|---|---|---:|---:|---:|---|
+| First open | Pre-stack reference | 249.0 ms | 253.8 ms | 100.7 ms | Completes |
+| First open | Repeated-snapshot regression | 4,197.5 ms | 4,284.8 ms | 4,197.9 ms | Exhausts heap |
+| Post-upgrade reopen | Pre-stack reference | 251.1 ms | 253.8 ms | 100.7 ms | Completes |
+| Post-upgrade reopen | Repeated-snapshot regression | 49.2 ms | 50.4 ms | 43.8 ms | Completes |
+
+The pre-stack implementation keeps V0 as its current format, so first open does not change its on-disk representation; its native V0 first-history and Agent-resume measurements therefore apply to both lifecycle rows.
 
 ## Alternatives considered
 
-**Extend the manual `test:web:perf` inventory.** Rejected: it stays outside CI by design, measures a simplified fold rather than the registered Definitions, and asserts nothing.
+**Check out the historical commit and compare it on every CI run.** Rejected because a historical checkout requires a separate install, and old and current revisions can assign work to different API phases, adding runtime, dependency, and interface drift. A fixed workload with static budgets calibrated against positive and negative controls is easier to reproduce and review.
+
+**Measure only first open from V0.** Rejected because migration is a one-time upgrade cost and cannot protect later opens of the settled current generation from regressions. The two access kinds need separate measurements and budgets.
+
+**Measure only the four component phases.** Rejected because component measurements locate cost but omit source stat, orchestration, pagination, and snapshot construction, and cannot prove that the complete first-history path remains usable and fast enough.
+
+**Measure only first-history or Agent-resume total time.** Rejected because an end-to-end number protects the result but cannot identify whether storage, reading, Session restoration, or projection regressed; four phase budgets retain actionable attribution.
 
-**Time-only budgets.** Rejected: a single absolute budget either fails on slower runners or passes a regression on faster ones; the heap cap and the scaling ratio give host-independent verdicts, and the wall-clock budget remains as the timeout that the user-visible symptom is about.
+**Add fine-grained timing instrumentation inside production implementations.** Rejected because those probes would expand production APIs and couple the benchmark to implementation details. Tests use existing service and object boundaries; costs that those boundaries cannot attribute remain part of the end-to-end result.
 
-**Benchmark the real recorded corpus.** Rejected: corpus fixtures are small by policy, recorded material must not become a benchmark input, and their re-recording would silently move the baseline.
+**Use only time budgets or only post-GC memory.** Rejected because time does not reveal memory regressions, while endpoint live memory cannot expose transient migration spikes. Normal-heap post-GC deltas and constrained-heap completion cover the two risks separately.
 
-**Run the benchmarks inside an existing gate aggregate.** Rejected: aggregates run gates concurrently on one runner, so wall-clock measurements would inherit the neighbours' CPU load.
+**Benchmark the real recorded corpus.** Rejected because corpus fixtures stay small by policy, recorded material must not become benchmark input, and re-recording would silently move the workload.
+
+**Run benchmarks inside an existing gate aggregate.** Rejected because aggregate gates run concurrently on one runner, so wall-clock measurements inherit neighbouring CPU load.
+
+**Keep each cross-package gate under one participating product package.** Rejected because Session opening spans persistence, migration, projection, Host history, and Agent resume; choosing one participant creates misleading ownership and benchmark-only package dependencies. The repository-level tree owns the integrated user path, while package-local diagnostics remain with their implementation.
+
+**Put benchmark cases under `scripts/`.** Rejected because scripts own commands, generators, and orchestration, while a benchmark case owns typed test files, workers, fixtures, budgets, and lifecycle cleanup. A future reporting or calibration command may consume `benchmarks/` without moving the cases there.
 
 ## Consequences
 
-Every pull request pays one more required Linux job of a few minutes, dominated by install time rather than the benchmarks themselves. A change that makes first open or the Client fold slower than its budget, heavier than its heap limit, or proportional to streamed deltas fails in the PR that introduces it, with the measured numbers printed in the job log. A budget change is a reviewed edit of the constant and its rationale comment, never an environment override, and a new benchmark must state which owner-visible path and which regression class it guards. The gate does not measure browser rendering, network transfer, or real recorded Sessions; those remain covered by the manual `test:web:perf` inventory and by review.
+Every pull request pays for one required Linux job; its Session portion runs several short-lived child processes in exchange for cold caches, isolated V8 heaps, explicit GC state, and attributable failures. The repository-level benchmark tree accepts deliberate cross-package test dependencies without changing product package manifests. The fixed Zstandard workload covers both event volume and frame topology; first-open measurements protect the one-time upgrade experience, reopen measurements prevent regressions in later opens, phase budgets locate cost, first-history budgets protect user-visible waiting, Agent-resume budgets and post-GC deltas protect complete cold activation and resident memory, and the 128 MB mode protects the transient allocation ceiling.
+
+The gate does not measure network transfer, browser rendering, or recorded Sessions, and it is not a continuous performance-trend system. A Node or runner change requires resampling the same workload and reviewing the budgets; a business-implementation change must not relax a budget without new positive and negative control data.

+ 58 - 13
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md

@@ -6,33 +6,78 @@ Status: implemented
 
 ## 问题
 
-Session format v2 的推出改变了两条成本随模型输出增长的路径:JSONL backend 在首次 `open()` 时迁移并发布 released-v0 log,Client 则 fold 每个已结算回复中嵌入的紧凑 stream。两条路径都没有可执行的性能检查,因此首次打开在 127,400 事件的合成 log 上从约 35 ms 增长到约 5 s(在 575,000 chunk 的真实 log 上从约 0.3 s 增长到 26 s,峰值 RSS 2.7 GB,并在 512 MB 堆限制下耗尽堆),以及 Client fold 随流式 delta 数而不是紧凑记录数线性增长,都未被察觉地进入了 master。单元测试使用小 log,coverage gate 只度量行数,现有的 `test:web:perf` 清单是 CI 之外的手动诊断。
+Session format v2 的推出改变了两条成本随模型输出增长的路径:JSONL backend 迁移并发布 released-v0 log,Client fold 每个已结算回复中嵌入的紧凑 stream。两条路径都没有可执行的性能检查,因此首次打开在 127,400 事件的合成 log 上从约 35 ms 增长到约 5 s(在 575,000 chunk 的真实 log 上从约 0.3 s 增长到 26 s,峰值 RSS 2.7 GB,并在 512 MB 堆限制下耗尽堆),Client fold 也随流式 delta 数而不是紧凑记录数线性增长,这些退化未被察觉地进入了 master。
+
+只测 `SessionPersistence.open()` 不能稳定表达用户或 Host 等待的结果。工作可以在 `open()`、`SessionHandle.read()`、Session restore 与 projection 之间移动,而首次历史页和冷 Agent 恢复还包含这些操作之上的独立编排。操作结束时未经 GC 的一次 `heapUsed` 采样也不能区分仍被 Session 持有的数据与可回收的迁移临时对象。
 
 ## 决定
 
-Linux pull request 运行必需的 `node 24 / benchmarks` job,执行 `pnpm run check:ci:bench` → `pnpm run test:bench` → `vitest.bench.config.ts`,后者收集 `packages/*/*/tests/**/*.bench.ts` 与 `*.bench.client.ts` 并逐文件运行。该 job 单独运行基准 lane,与其他必需 Linux worker 使用同一 runner 选择器和 failover 开关,并加入 `all checks passed` 判定。
+Linux pull request 运行必需的 `node 24 / benchmarks` job,执行 `pnpm run check:ci:bench` → `pnpm run test:bench` → `vitest.bench.config.ts`。该 job 单独运行 benchmark lane;Vitest 逐文件运行,测试进程只负责准备输入、启动测量子进程、汇总结果和执行预算断言。
+
+必需性能 gate 位于顶层 `benchmarks/`,按被测用户路径而非 package 归属组织。Host 文件使用 `*.bench.ts`,Client 面文件使用 `*.bench.client.ts`,场景专属 worker 与 fixture 留在对应 benchmark 旁且不带 benchmark 后缀。包内 `.perf.ts` 文件仍是非门禁诊断;`scripts/` 负责编排而不承载 benchmark case。
+
+Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。
 
-每个基准都在进程内按固定参数合成输入:编号的 prompt、计数 token、固定时间戳。绝不使用录制的 Session,因为它们携带用户内容、在不同机器上不同,并随 fixture 重新录制而漂移。每个基准在强制执行预算的常量旁记录其预算,预算遵循三条规则:壁钟预算取目标成本的小倍数并远低于所防护的回归;内存预算把被测路径放在固定 `--max-old-space-size` 的子 Node 进程中运行,使分配回归无论 runner 物理内存多大都以 out-of-memory 退出失败;缩放断言比较同一负载的两个规模,使复杂度回归在任何主机速度下都失败。
+每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,因此包含 migration 与后继 generation 发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的 V2 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache
 
-前两个 gate 覆盖两条回归路径:
+每个 access kind 与 endpoint 的样本都在全新 Node 子进程中运行。模块加载、Host 服务初始化和 fixture 准备在测量开始前完成;测量进程不执行额外的预热解析。正常堆模式运行五个独立样本,报告全部样本及最小值、中位数和最大值,并以中位数执行各访问状态独立的固定预算。另一个子进程使用固定 128 MB old-space 上限运行同一路径,只判断能否完成;低堆限制引起的额外 GC 不进入正常时间基线。
 
-| 基准 | 负载 | Gate |
+该 lane 包含三个独立的 Session 打开 benchmark,并保留 Client fold benchmark:
+
+| Benchmark | 被测路径 | 时间指标 |
 |---|---|---|
-| `packages/session/session-persistence-jsonl/tests/open-generation.bench.ts` | 200 轮 ×(500 text + 125 reasoning delta)= 127,400 个 released-v0 事件,约 2.8 MB,经冻结 v0 codec 以 packed row 编码 | 迁移的首次 `open()` 在 128 MB 堆下 ≤ 4,000 ms;新进程打开已发布 current generation ≤ 500 ms;三次尝试取最小值 |
-| `packages/client/ui-chat/tests/conversation-fold.bench.client.ts` | 200 个回复,每个紧凑 stream 含 2,000 text + 500 reasoning delta(1,600 条记录中 500,000 个 delta),由真实 `ConversationNodeAssembler` 经全部 Chat Definition fold | 大窗口 fold ≤ 150 ms;大窗口 fold ≤ 每回复 100 delta 的同一窗口的 5 倍 |
+| 阶段剖面 | 分别为 first open 与 post-upgrade reopen 执行真实 persistence open、handle read、Session restore 与 projection | `openMs`、`readMs`、`sessionRestoreMs`、`projectionMs` 各自使用固定预算;migration 所等待的编码、写入、verify 与 publish 全部归入 first-open `openMs` |
+| 首屏历史 | 两种 access kind 分别经 Host Session history controller 读取到首个分页 snapshot | First open 与 reopen 各有一个端到端预算;均包含 source stat、读取、Session restore、projection、分页与 snapshot 构造,first open 还包含 migration;两者都不包含 Gateway 网络传输、Client fold 或浏览器 paint |
+| Agent resume | 对两种 access kind 分别调用 `ctx.agents.resume()`,直到 Agent 创建、setup、发布与 loop 启动完成 | First open 与 reopen 各有一个端到端预算;两条路径都不与首屏历史串行,也不依赖它留下的 cache |
+| Client fold | 大小两个 v2 history window 经真实 `ConversationNodeAssembler` 与全部 Chat Definition fold | 大窗口的绝对时间与相对小窗口的缩放比各自使用固定预算 |
+
+阶段剖面显式调用各层正式入口,不复制 decode、migration、restore 或 projection 算法。首屏历史和 Agent resume 分别以新的 first-open 与 reopen 根目录运行真实上层入口,因此组件数据不冒充端到端结果,一个场景也不会给另一个场景预热进程或 Session cache。四阶段之和仅用于解释成本;首屏与 Agent resume 的端到端时间各自由外层时钟直接测量。
+
+正常堆模式在 Host 初始化完成且 Session 尚未访问时执行固定的两轮显式 GC,记录起点内存;操作计时结束后,在该场景要求的长期对象仍明确可达时再次执行同样的 GC,再记录终点内存。Agent resume 场景在终点保留 Agent、Session、完整 events 与正常服务 cache,它的 `heapUsed` 增量是常驻 Session 内存预算的主指标。每个场景同时报告 `external`、`arrayBuffers`、GC 后 RSS 和 `process.resourceUsage().maxRSS`;128 MB 模式继续防止瞬时分配峰值被终点 GC 隐藏。显式 GC 时间不计入操作时间。
+
+性能 gate 不重复功能测试的内容断言,只要求目标调用完成并到达对应的可观察终点。Client fold benchmark 继续使用真实 `ConversationNodeAssembler` 与全部 Chat Definition,要求大窗口的绝对时间和相对小窗口的缩放比均低于固定预算。
+
+预算以最终实现于目标 CI runner 上的多次样本为基线,并保留足以吸收 runner 波动、但仍能区分已知退化的余量。栈前参考提交固定为 `0d7ea53743e273930a31e9e2b6ca682f21dd4ca5`,只用于校准和评审预算;CI 不 checkout 或执行历史仓库。预算是源码中的受评审常量,不由环境变量覆盖。
+
+## 校准证据
+
+比较按用户生命周期正交,而不是按产物表示正交。两种实现的 first open 都接收完全相同的固定 V0 字节。Reopen 时,每种实现都在全新进程中读取自己认定的当前格式:栈前参考版本仍读取 V0,V2 实现则读取它已发布的 V2 后继。这里有意比较同一用户后续打开的体验,而不是让两个 codec 处理同一种数据结构。
 
-在引入该 gate 的提交上于参考机器测得:迁移基准耗尽 128 MB 堆,fold 基准在小窗口与大窗口之间缩放 11 倍,因此两个 gate 都在回归代码上失败,并在两条路径改为 O(records) 工作后通过。
+同一台 Node 24 参考机器上的五次样本中位数构成正反例:
+
+| Access kind | 实现 | 四阶段总时间 | 首屏历史 | Agent resume | 128 MB old space |
+|---|---|---:|---:|---:|---|
+| First open | 栈前参考版本 | 249.0 ms | 253.8 ms | 100.7 ms | 完成 |
+| First open | 重复 snapshot 退化实现 | 4,197.5 ms | 4,284.8 ms | 4,197.9 ms | 堆耗尽 |
+| Post-upgrade reopen | 栈前参考版本 | 251.1 ms | 253.8 ms | 100.7 ms | 完成 |
+| Post-upgrade reopen | 重复 snapshot 退化实现 | 49.2 ms | 50.4 ms | 43.8 ms | 完成 |
+
+栈前实现以 V0 作为当前格式,因此 first open 不改变磁盘表示;它的原生 V0 首屏历史与 Agent resume 测量同时适用于两个生命周期行。
 
 ## 考虑过的替代方案
 
-**扩展手动 `test:web:perf` 清单。** 拒绝:它有意留在 CI 之外,测量的是简化 fold 而非已注册的 Definition,且不做任何断言。
+**每次 CI checkout 历史提交并做相对比较。** 拒绝:历史 checkout 需要独立安装,旧版与当前版还可能把工作放在不同 API 阶段,增加时间、依赖和接口漂移。固定 workload 与经正反例校准的静态预算更容易复现和评审。
+
+**只测从 V0 first open。** 拒绝:migration 是一次性升级成本,不能防止进入稳定当前 generation 后的后续打开发生退化。两种 access kind 需要独立的测量与预算。
+
+**只测四个组件阶段。** 拒绝:组件测量便于定位,但会遗漏 source stat、编排、分页和 snapshot 构造,也不能证明首屏路径整体仍然可用且足够快。
+
+**只测首屏或 Agent resume 总时间。** 拒绝:端到端数字能保护结果,却不能指出退化来自存储、读取、Session restore 还是 projection;四阶段预算保留可操作的归因。
 
-**只用时间预算。** 拒绝:单一绝对预算要么在较慢的 runner 上失败,要么在较快的 runner 上放过回归;堆上限与缩放比给出与主机无关的判定,壁钟预算则作为用户可见症状所对应的超时保留。
+**在生产实现内部添加细粒度计时桩。** 拒绝:这些桩会扩大生产接口并让 benchmark 与实现细节耦合。测试只使用既有服务和对象边界;无法由这些边界解释的成本保留在端到端结果中
 
-**用真实录制语料做基准。** 拒绝:语料 fixture 按策略保持小体量,录制材料不得成为基准输入,且其重新录制会静默移动基线。
+**只用时间预算或只看 GC 后内存。** 拒绝:时间无法发现内存退化,终点存活内存也看不到迁移期间的瞬时爆发。正常堆的 GC 后增量与受限堆的完成性分别覆盖两类风险
 
-**把基准放进现有 gate 聚合中运行。** 拒绝:聚合在一个 runner 上并发运行各 gate,壁钟测量会继承邻居的 CPU 负载。
+**用真实录制语料做 benchmark。** 拒绝:语料 fixture 按策略保持小体量,录制材料不得成为 benchmark 输入,且其重新录制会静默移动 workload。
+
+**把 benchmark 放进现有 gate 聚合中运行。** 拒绝:聚合在一个 runner 上并发运行各 gate,壁钟测量会继承邻居的 CPU 负载。
+
+**把每个跨包 gate 放在一个参与的产品 package 下。** 拒绝:Session 打开跨越 persistence、migration、projection、Host history 与 Agent resume;任选一个参与方都会形成误导性的归属和仅为 benchmark 增加的 package 依赖。仓库级目录拥有集成用户路径,包内诊断仍留在对应实现旁。
+
+**把 benchmark case 放在 `scripts/` 下。** 拒绝:scripts 拥有命令、生成器与编排,而 benchmark case 拥有带类型的测试文件、worker、fixture、预算和生命周期清理。未来的报告或校准命令可以消费 `benchmarks/`,不需要把 case 移入其中。
 
 ## 后果
 
-每个 pull request 多付出一个几分钟的必需 Linux job,其时间主要花在安装而不是基准本身。让首次打开或 Client fold 慢于预算、重于堆限制或与流式 delta 数成正比的改动,会在引入它的 PR 中失败,并把测得的数字打印在 job 日志里。预算变更是对常量及其理由注释的受评审编辑,绝不是环境变量覆盖;新增基准必须说明它防护哪条 owner 可见路径和哪类回归。该 gate 不测量浏览器渲染、网络传输或真实录制的 Session;这些仍由手动 `test:web:perf` 清单和评审覆盖。
+每个 pull request 多付出一个必需 Linux job;该 job 的 Session 部分运行多个短生命周期子进程,以换取冷 cache、独立 V8 heap、明确 GC 状态和可归因的失败。仓库级 benchmark 目录接受有意的跨包测试依赖,而不修改产品 package manifest。固定 Zstandard workload 同时覆盖事件规模与 frame 拓扑;first-open 测量保护一次性升级体验,reopen 测量防止后续打开退化,四阶段预算定位成本归属,首屏预算保护用户可见等待,Agent resume 预算与 GC 后增量保护完整冷恢复及常驻内存,128 MB 模式保护瞬时分配上限。
+
+该 gate 不测量网络传输、浏览器渲染或真实录制 Session,也不是持续性能趋势系统。Node 或 runner 变化需要用同一 workload 重新采样并评审预算;修改业务实现时不得顺带放宽预算而不提供新的正反例数据。

+ 3 - 2
AGENTS.md

@@ -48,11 +48,12 @@ packages/    @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
   experimental/ private prototypes excluded from official releases
   support/     dev/test infrastructure
   util/        zero-dependency utilities
-python/      Python SDK and bundled runtime (see python/README.md)
+python/      Python SDK/runtime (see python/README.md)
 native/      @deepseek-ai/node-addon-landlock-run source of record (see native/README.md)
+benchmarks/  cross-package performance gates
 .agents/     Agent workflows and Agent Notes (`notes/`)
 docs/        architecture, generated catalogs, postmortems, cookbook (see docs/AGENTS.md)
-scripts/     repo gates and generators
+scripts/     gates and generators
 website/     VitePress projection of selected bilingual docs/ sources
 ```
 

+ 12 - 0
benchmarks/AGENTS.md

@@ -0,0 +1,12 @@
+# AGENTS.md — Performance Benchmarks
+
+This tree owns required, repository-level performance gates whose measured user path crosses package ownership. Package-local diagnostics remain beside their owners and use the `.perf.ts` suffix instead of joining `test:bench`.
+
+- Organize benchmarks by measured user path, one directory per path. Do not mirror the package tree.
+- Host cases use `*.bench.ts`; Client-face cases use `*.bench.client.ts`. Worker, fixture, and support modules do not carry a benchmark suffix.
+- Synthesize fixed inputs from reviewed constants. Never use recorded Sessions, user material, ambient repositories, or network services.
+- Run process-level wall-clock and retained-memory samples in fresh children with private `mkdtemp` roots. Pure synchronous folds create a fresh object graph per sample and must not mutate process-global state. Bound every child, await exit, and remove owned roots after failure as well as success.
+- Report enough raw and aggregate measurements to explain each verdict, including whether a budget uses a median, minimum, absolute value, or ratio. Enforce reviewed source constants; environment variables must not override performance budgets.
+- Keep scenario-specific support beside its benchmark. Move a helper into `benchmarks/support/` only after at least two benchmark directories require the same behavior.
+- Exercise production entry points. Do not copy product algorithms, add production exports solely for measurement, or turn benchmark completion into duplicate semantic assertions.
+- Record the workload, timing boundary, memory endpoint, calibration reference, alternatives, and known exclusions in the owning Agent Note.

+ 14 - 14
packages/client/ui-chat/tests/conversation-fold.bench.client.ts → benchmarks/conversation-fold/conversation-fold.bench.client.ts

@@ -18,20 +18,20 @@ import {
   type ConversationNodeDefinition,
   type ConversationViewDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
-import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
-import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
-import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
-import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
-import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
-import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
-import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
-import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
-import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
-import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
-import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
-import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
-import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
+import { assistantDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts'
+import { chatViewDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
+import { commandDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/command.ts'
+import { compactionDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/compaction.ts'
+import { unknownFallbackDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/fallback.ts'
+import { nextStepInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts'
+import { messageDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/message.ts'
+import { requestPromptDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts'
+import { retryDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/retry.ts'
+import { toolDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts'
+import { turnErrorDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts'
+import { turnMaxTokensDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts'
+import { turnProcessDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts'
+import { turnTailDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts'
 
 /** Replies in the folded window; each carries one reasoning block and one text block. */
 const TURNS = 200

+ 370 - 0
benchmarks/session-open/session-open.bench.ts

@@ -0,0 +1,370 @@
+/** Required performance budgets for cold Session preparation, first history, and Agent resume. */
+
+import { spawn } from 'node:child_process'
+import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type {
+  SessionOpenBenchmarkScenario,
+  SessionOpenWorkerReport,
+} from './session-open.bench.worker.ts'
+import {
+  SYNTHETIC_SESSION_DIRECTORY,
+  SYNTHETIC_CURRENT_FILENAME,
+  SYNTHETIC_V0_FILENAME,
+  writeSyntheticReleasedV0Session,
+  type SyntheticV0SessionWrite,
+} from './synthetic-released-v0-session.ts'
+
+/** 200 turns × (500 text + 125 reasoning deltas): 127,400 released-v0 events. */
+const SHAPE = { turns: 200, textDeltas: 500 } as const
+/** Fresh processes per normal-heap scenario; the median enforces each timing budget. */
+const ATTEMPTS = 5
+/** A stuck child is a benchmark failure and must be reaped before another sample starts. */
+const WORKER_TIMEOUT_MS = 120_000
+/** Old-space pressure check, kept independent from normal-heap timing samples. */
+const CONSTRAINED_HEAP_MB = 128
+
+type SessionAccessKind = 'first-open' | 'post-upgrade-reopen'
+type SessionBenchmarkEndpoint = 'phases' | 'first-history' | 'agent-resume'
+
+const SOURCE_GENERATION_BY_ACCESS = {
+  'first-open': 'released-v0',
+  'post-upgrade-reopen': 'current-v2',
+} as const satisfies Record<SessionAccessKind, string>
+
+/** Existing CI calibration: optimized migration is about 2 s and the repeated-snapshot path exceeds 4 s. */
+const MIGRATION_OPEN_BUDGET_MS = 4_000
+/** Current-generation open is expected to stay far below one second on the benchmark runner. */
+const REOPEN_OPEN_BUDGET_MS = 500
+/** Complete event reads remain bounded after either opening path. */
+const READ_BUDGET_MS = 500
+/** Restoring the detached in-memory Session must remain below the migration budget's spare second. */
+const SESSION_RESTORE_BUDGET_MS = 1_000
+/** The fixed production projection set must fold the complete Session within one second. */
+const PROJECTION_BUDGET_MS = 1_000
+/** Host first-history includes migration, restore, projection, and bounded page construction. */
+const FIRST_OPEN_FIRST_HISTORY_BUDGET_MS = 6_000
+/** An already-published V2 Session should produce first history without migration-scale work. */
+const REOPEN_FIRST_HISTORY_BUDGET_MS = 500
+/** Cold Agent resume includes migration, Session restore, Agent setup, publication, and loop startup. */
+const FIRST_OPEN_AGENT_RESUME_BUDGET_MS = 7_000
+/** An already-published V2 Session should resume without migration-scale work. */
+const REOPEN_AGENT_RESUME_BUDGET_MS = 500
+/** Live Agent, Session, events, and normal caches retained after full GC. */
+const AGENT_RETAINED_HEAP_BUDGET_MB = 192
+
+const WORKER = join(import.meta.dirname, 'session-open.bench.worker.ts')
+
+interface WorkerRun {
+  readonly report: SessionOpenWorkerReport | undefined
+  readonly exitCode: number | null
+  readonly signal: NodeJS.Signals | null
+  readonly timedOut: boolean
+  readonly stderr: string
+}
+
+function rounded(value: number): number {
+  return Math.round(value * 10) / 10
+}
+
+function median(values: readonly number[]): number {
+  const sorted = [...values].sort((left, right) => left - right)
+  return sorted[Math.floor(sorted.length / 2)] as number
+}
+
+function metric(
+  reports: readonly SessionOpenWorkerReport[],
+  read: (report: SessionOpenWorkerReport) => number,
+): { readonly min: number; readonly median: number; readonly max: number; readonly samples: readonly number[] } {
+  const samples = reports.map(read)
+  return {
+    min: rounded(Math.min(...samples)),
+    median: rounded(median(samples)),
+    max: rounded(Math.max(...samples)),
+    samples: samples.map(rounded),
+  }
+}
+
+function phaseMetric(
+  reports: readonly SessionOpenWorkerReport[],
+  key: keyof NonNullable<SessionOpenWorkerReport['phases']>,
+): ReturnType<typeof metric> {
+  return metric(reports, (report) => {
+    if (report.phases === undefined) throw new Error(`${report.scenario} did not report phase timings`)
+    return report.phases[key]
+  })
+}
+
+function summarize(reports: readonly SessionOpenWorkerReport[]) {
+  return {
+    totalMs: metric(reports, report => report.totalMs),
+    cpuUserMs: metric(reports, report => report.cpuUserMs),
+    cpuSystemMs: metric(reports, report => report.cpuSystemMs),
+    retainedHeapMb: metric(reports, report => report.retained.heapUsedMb),
+    retainedExternalMb: metric(reports, report => report.retained.externalMb),
+    retainedArrayBuffersMb: metric(reports, report => report.retained.arrayBuffersMb),
+    retainedRssMb: metric(reports, report => report.retained.rssMb),
+    peakRssMb: metric(reports, report => report.afterGc.peakRssMb),
+  }
+}
+
+function summarizePhases(reports: readonly SessionOpenWorkerReport[]) {
+  return {
+    ...summarize(reports),
+    openMs: phaseMetric(reports, 'openMs'),
+    readMs: phaseMetric(reports, 'readMs'),
+    sessionRestoreMs: phaseMetric(reports, 'sessionRestoreMs'),
+    projectionMs: phaseMetric(reports, 'projectionMs'),
+  }
+}
+
+function runWorker(
+  root: string,
+  scenario: SessionOpenBenchmarkScenario,
+  heapLimitMb?: number,
+): Promise<WorkerRun> {
+  return new Promise((resolve, reject) => {
+    const child = spawn(process.execPath, [
+      '--expose-gc',
+      ...heapLimitMb === undefined ? [] : [`--max-old-space-size=${String(heapLimitMb)}`],
+      '--import',
+      'tsx/esm',
+      WORKER,
+      root,
+      scenario,
+    ], { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] })
+    let stdout = ''
+    let stderr = ''
+    let timedOut = false
+    const timeout = setTimeout(() => {
+      timedOut = true
+      child.kill('SIGKILL')
+    }, WORKER_TIMEOUT_MS)
+    child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk })
+    child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
+    child.once('error', (error) => {
+      clearTimeout(timeout)
+      reject(error)
+    })
+    child.once('close', (exitCode, signal) => {
+      clearTimeout(timeout)
+      const line = stdout.trim().split('\n').findLast(candidate => candidate.startsWith('{'))
+      let report: SessionOpenWorkerReport | undefined
+      if (exitCode === 0 && line !== undefined) report = JSON.parse(line) as SessionOpenWorkerReport
+      resolve({ report, exitCode, signal, timedOut, stderr })
+    })
+  })
+}
+
+function requireReport(
+  run: WorkerRun,
+  scenario: SessionOpenBenchmarkScenario,
+  heapLimitMb?: number,
+): SessionOpenWorkerReport {
+  if (run.report !== undefined) return run.report
+  const stderrLines = run.stderr.trim().split('\n')
+  const fatal = stderrLines.filter(line => /FATAL ERROR|heap limit|out of memory/i.test(line))
+  const detail = (fatal.length > 0 ? fatal : stderrLines.slice(-10)).join('\n')
+  const limit = heapLimitMb === undefined ? 'normal heap' : `${String(heapLimitMb)} MB old space`
+  throw new Error(
+    `${scenario} failed under ${limit}: exit=${String(run.exitCode)}, signal=${String(run.signal)}, `
+    + `timedOut=${String(run.timedOut)}\n${detail}`,
+  )
+}
+
+/** Owns deterministic first-open/reopen sources and private roots created for one benchmark file. */
+class SessionOpenBenchmarkSuite {
+  private legacySourcePath = ''
+  private currentSourcePath = ''
+  private scratch = ''
+  private facts: SyntheticV0SessionWrite | undefined
+  private rootIndex = 0
+
+  async prepare(): Promise<void> {
+    this.scratch = await mkdtemp(join(tmpdir(), 'dsh-session-open-bench-'))
+    this.facts = await writeSyntheticReleasedV0Session(join(this.scratch, 'source'), SHAPE)
+    this.legacySourcePath = this.facts.path
+    // Produce one real post-upgrade directory outside every measured interval.
+    const templateRoot = await this.createRoot('first-open', 'post-upgrade-template')
+    requireReport(await runWorker(templateRoot, 'phase-migrate'), 'phase-migrate')
+    this.currentSourcePath = join(
+      templateRoot,
+      SYNTHETIC_SESSION_DIRECTORY,
+      SYNTHETIC_CURRENT_FILENAME,
+    )
+  }
+
+  async dispose(): Promise<void> {
+    await rm(this.scratch, { recursive: true, force: true })
+  }
+
+  workload(accessKind: SessionAccessKind) {
+    if (this.facts === undefined) throw new Error('Session opening benchmark source is not prepared')
+    return {
+      accessKind,
+      sourceGeneration: SOURCE_GENERATION_BY_ACCESS[accessKind],
+      logicalInputEvents: this.facts.events,
+      legacyInputRows: this.facts.rows,
+      legacyInputFrames: this.facts.frames,
+      legacyInputLogicalBytes: this.facts.logicalBytes,
+      legacyInputCompressedBytes: this.facts.compressedBytes,
+    }
+  }
+
+  async sample(
+    accessKind: SessionAccessKind,
+    endpoint: SessionBenchmarkEndpoint,
+  ): Promise<SessionOpenWorkerReport[]> {
+    const reports: SessionOpenWorkerReport[] = []
+    for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
+      reports.push(await this.run(accessKind, endpoint))
+    }
+    return reports
+  }
+
+  async run(
+    accessKind: SessionAccessKind,
+    endpoint: SessionBenchmarkEndpoint,
+    heapLimitMb?: number,
+  ): Promise<SessionOpenWorkerReport> {
+    const scenario = this.workerScenario(accessKind, endpoint)
+    const root = await this.createRoot(
+      accessKind,
+      `${accessKind}-${endpoint}-${String(this.rootIndex++)}`,
+    )
+    const report = requireReport(await runWorker(root, scenario, heapLimitMb), scenario, heapLimitMb)
+    return report
+  }
+
+  private workerScenario(
+    accessKind: SessionAccessKind,
+    endpoint: SessionBenchmarkEndpoint,
+  ): SessionOpenBenchmarkScenario {
+    if (endpoint !== 'phases') return endpoint
+    return accessKind === 'first-open' ? 'phase-migrate' : 'phase-steady'
+  }
+
+  private async createRoot(accessKind: SessionAccessKind, label: string): Promise<string> {
+    const root = join(this.scratch, label)
+    const directory = join(root, SYNTHETIC_SESSION_DIRECTORY)
+    await mkdir(directory, { recursive: true })
+    await copyFile(this.legacySourcePath, join(directory, SYNTHETIC_V0_FILENAME))
+    if (accessKind === 'post-upgrade-reopen') {
+      if (this.currentSourcePath === '') throw new Error('current V2 benchmark source is not prepared')
+      // Released generations remain adjacent after migration, so V2 samples retain their V0 predecessor.
+      await copyFile(this.currentSourcePath, join(directory, SYNTHETIC_CURRENT_FILENAME))
+    }
+    return root
+  }
+}
+
+interface AccessBenchmarkSpec {
+  readonly accessKind: SessionAccessKind
+  readonly label: string
+  readonly openBudgetMs: number
+  readonly firstHistoryBudgetMs: number
+  readonly agentResumeBudgetMs: number
+}
+
+const ACCESS_BENCHMARKS: readonly AccessBenchmarkSpec[] = [
+  {
+    accessKind: 'first-open',
+    label: 'first open from released V0',
+    openBudgetMs: MIGRATION_OPEN_BUDGET_MS,
+    firstHistoryBudgetMs: FIRST_OPEN_FIRST_HISTORY_BUDGET_MS,
+    agentResumeBudgetMs: FIRST_OPEN_AGENT_RESUME_BUDGET_MS,
+  },
+  {
+    accessKind: 'post-upgrade-reopen',
+    label: 'fresh-process reopen after upgrade',
+    openBudgetMs: REOPEN_OPEN_BUDGET_MS,
+    firstHistoryBudgetMs: REOPEN_FIRST_HISTORY_BUDGET_MS,
+    agentResumeBudgetMs: REOPEN_AGENT_RESUME_BUDGET_MS,
+  },
+]
+
+describe('opening a large Session for first open and post-upgrade reopen', () => {
+  const suite = new SessionOpenBenchmarkSuite()
+
+  beforeAll(async () => { await suite.prepare() })
+  afterAll(async () => { await suite.dispose() })
+
+  for (const access of ACCESS_BENCHMARKS) {
+    describe(access.label, () => {
+      it('profiles all four phases under normal heap', async () => {
+        const result = summarizePhases(await suite.sample(access.accessKind, 'phases'))
+        console.log(JSON.stringify({
+          benchmark: `session-open/${access.accessKind}/phases`,
+          ...suite.workload(access.accessKind),
+          result,
+          budgetsMs: {
+            open: access.openBudgetMs,
+            read: READ_BUDGET_MS,
+            sessionRestore: SESSION_RESTORE_BUDGET_MS,
+            projection: PROJECTION_BUDGET_MS,
+          },
+        }))
+        expect(result.openMs.median).toBeLessThanOrEqual(access.openBudgetMs)
+        expect(result.readMs.median).toBeLessThanOrEqual(READ_BUDGET_MS)
+        expect(result.sessionRestoreMs.median).toBeLessThanOrEqual(SESSION_RESTORE_BUDGET_MS)
+        expect(result.projectionMs.median).toBeLessThanOrEqual(PROJECTION_BUDGET_MS)
+      })
+
+      it(`completes all four phases under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
+        const report = await suite.run(access.accessKind, 'phases', CONSTRAINED_HEAP_MB)
+        console.log(JSON.stringify({
+          benchmark: `session-open/${access.accessKind}/phases-constrained`,
+          ...suite.workload(access.accessKind),
+          heapLimitMb: CONSTRAINED_HEAP_MB,
+          report,
+        }))
+      })
+
+      it(`produces first Host history within ${String(access.firstHistoryBudgetMs)} ms`, async () => {
+        const result = summarize(await suite.sample(access.accessKind, 'first-history'))
+        console.log(JSON.stringify({
+          benchmark: `session-open/${access.accessKind}/first-history`,
+          ...suite.workload(access.accessKind),
+          result,
+          budgetMs: access.firstHistoryBudgetMs,
+        }))
+        expect(result.totalMs.median).toBeLessThanOrEqual(access.firstHistoryBudgetMs)
+      })
+
+      it(`produces first Host history under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
+        const report = await suite.run(access.accessKind, 'first-history', CONSTRAINED_HEAP_MB)
+        console.log(JSON.stringify({
+          benchmark: `session-open/${access.accessKind}/first-history-constrained`,
+          ...suite.workload(access.accessKind),
+          heapLimitMb: CONSTRAINED_HEAP_MB,
+          report,
+        }))
+      })
+
+      it(`resumes a cold Agent within ${String(access.agentResumeBudgetMs)} ms`, async () => {
+        const result = summarize(await suite.sample(access.accessKind, 'agent-resume'))
+        console.log(JSON.stringify({
+          benchmark: `session-open/${access.accessKind}/agent-resume`,
+          ...suite.workload(access.accessKind),
+          result,
+          budgetMs: access.agentResumeBudgetMs,
+          retainedHeapBudgetMb: AGENT_RETAINED_HEAP_BUDGET_MB,
+        }))
+        expect(result.totalMs.median).toBeLessThanOrEqual(access.agentResumeBudgetMs)
+        expect(result.retainedHeapMb.median).toBeLessThanOrEqual(AGENT_RETAINED_HEAP_BUDGET_MB)
+      })
+
+      it(`resumes a cold Agent under a ${String(CONSTRAINED_HEAP_MB)} MB old-space limit`, async () => {
+        const report = await suite.run(access.accessKind, 'agent-resume', CONSTRAINED_HEAP_MB)
+        console.log(JSON.stringify({
+          benchmark: `session-open/${access.accessKind}/agent-resume-constrained`,
+          ...suite.workload(access.accessKind),
+          heapLimitMb: CONSTRAINED_HEAP_MB,
+          report,
+        }))
+      })
+    })
+  }
+})

+ 299 - 0
benchmarks/session-open/session-open.bench.worker.ts

@@ -0,0 +1,299 @@
+/** Isolated worker for cold Session phase, first-history, and Agent-resume benchmarks. */
+
+import { performance } from 'node:perf_hooks'
+import { scheduler } from 'node:timers/promises'
+import { Context } from '@deepseek-ai/cordis'
+import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
+import SessionStore, {
+  interruptedTurnClosers,
+  SessionId,
+  SessionLogOffset,
+  SessionPreparation,
+} from '@deepseek-ai/dsh-session'
+import type { AgentHandle } from '@deepseek-ai/dsh-agent'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+import type {
+  SessionEventSearchPage,
+  SessionEventSearchRequest,
+  SessionSearchExecContext,
+  SessionSearchHit,
+  SessionSearchPage,
+  SessionSearchRequest,
+} from '@deepseek-ai/dsh-session-query'
+import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
+import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline'
+import TokenMeter from '@deepseek-ai/dsh-token-meter'
+import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts'
+import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts'
+import { SYNTHETIC_SESSION_ID } from './synthetic-released-v0-session.ts'
+
+/** Worker scenario selected by the parent benchmark. */
+export type SessionOpenBenchmarkScenario =
+  | 'phase-migrate'
+  | 'phase-steady'
+  | 'first-history'
+  | 'agent-resume'
+
+/** One post-GC process memory observation. */
+export interface BenchmarkMemorySnapshot {
+  readonly heapUsedMb: number
+  readonly externalMb: number
+  readonly arrayBuffersMb: number
+  readonly rssMb: number
+  readonly peakRssMb: number
+}
+
+/** Memory retained by one benchmark endpoint relative to its initialized Host. */
+export interface BenchmarkMemoryDelta {
+  readonly heapUsedMb: number
+  readonly externalMb: number
+  readonly arrayBuffersMb: number
+  readonly rssMb: number
+}
+
+/** Timings and memory emitted by one isolated scenario. */
+export interface SessionOpenWorkerReport {
+  readonly scenario: SessionOpenBenchmarkScenario
+  readonly totalMs: number
+  readonly phases?: {
+    readonly openMs: number
+    readonly readMs: number
+    readonly sessionRestoreMs: number
+    readonly projectionMs: number
+  }
+  readonly cpuUserMs: number
+  readonly cpuSystemMs: number
+  readonly events: number
+  readonly beforeGc: BenchmarkMemorySnapshot
+  readonly afterGc: BenchmarkMemorySnapshot
+  readonly retained: BenchmarkMemoryDelta
+}
+
+class BenchmarkSessionQuery extends SessionQueryEngine {
+  override searchSessions(
+    _request: SessionSearchRequest,
+    _exec?: SessionSearchExecContext,
+  ): Promise<SessionSearchPage<SessionSearchHit>> {
+    return Promise.reject(new Error('search is outside the Session opening benchmark'))
+  }
+
+  override searchEvents(
+    _request: SessionEventSearchRequest,
+    _exec?: SessionSearchExecContext,
+  ): Promise<SessionEventSearchPage> {
+    return Promise.reject(new Error('search is outside the Session opening benchmark'))
+  }
+}
+
+function megabytes(bytes: number): number {
+  return Math.round(bytes / 104_857.6) / 10
+}
+
+function memorySnapshot(): BenchmarkMemorySnapshot {
+  const memory = process.memoryUsage()
+  return {
+    heapUsedMb: megabytes(memory.heapUsed),
+    externalMb: megabytes(memory.external),
+    arrayBuffersMb: megabytes(memory.arrayBuffers),
+    rssMb: megabytes(memory.rss),
+    peakRssMb: Math.round(process.resourceUsage().maxRSS / 102.4) / 10,
+  }
+}
+
+async function collectGarbage(): Promise<BenchmarkMemorySnapshot> {
+  const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc
+  if (gc === undefined) throw new Error('Session opening benchmark requires --expose-gc')
+  gc()
+  await scheduler.yield()
+  gc()
+  return memorySnapshot()
+}
+
+function memoryDelta(
+  before: BenchmarkMemorySnapshot,
+  after: BenchmarkMemorySnapshot,
+): BenchmarkMemoryDelta {
+  return {
+    heapUsedMb: Math.round((after.heapUsedMb - before.heapUsedMb) * 10) / 10,
+    externalMb: Math.round((after.externalMb - before.externalMb) * 10) / 10,
+    arrayBuffersMb: Math.round((after.arrayBuffersMb - before.arrayBuffersMb) * 10) / 10,
+    rssMb: Math.round((after.rssMb - before.rssMb) * 10) / 10,
+  }
+}
+
+async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise<void> {
+  if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+  ctx.sessionProjections.register(agentPresetProjectionDefinition)
+  installModelSelectionProjection(ctx)
+  await ctx.plugin(SessionTitleService, {
+    fallbackMaxWords: 5,
+    fallbackMaxBytes: 40,
+    maxTitleBytes: 80,
+  })
+  await ctx.plugin(SessionStatsPlugin)
+  await ctx.plugin(SessionTurnOutlinePlugin)
+  await ctx.plugin(TokenMeter)
+}
+
+/** Owns one initialized Host and the live endpoint retained through its final GC sample. */
+class SessionBenchmarkHost {
+  private preparation: SessionPreparation | undefined
+  private agentHandle: AgentHandle | undefined
+  private historyAbort: AbortController | undefined
+  private historyIterator: AsyncIterator<unknown> | undefined
+  private retained: unknown
+
+  private constructor(
+    private readonly ctx: Context,
+    private readonly scenario: SessionOpenBenchmarkScenario,
+  ) {}
+
+  static async create(root: string, scenario: SessionOpenBenchmarkScenario): Promise<SessionBenchmarkHost> {
+    const ctx = new Context()
+    await ctx.plugin(SessionProjectionRegistry)
+    const agentScenario = scenario === 'agent-resume'
+    if (agentScenario) await mountAgentLoopTestDependencies(ctx)
+    else await ctx.plugin(SessionStore)
+    await installProjectionSet(ctx, agentScenario)
+    await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
+    if (scenario === 'first-history') new BenchmarkSessionQuery(ctx)
+    if (agentScenario) await ctx.plugin(AgentLoop, { agents: [] })
+    return new SessionBenchmarkHost(ctx, scenario)
+  }
+
+  async measure(): Promise<SessionOpenWorkerReport> {
+    const beforeGc = await collectGarbage()
+    const started = performance.now()
+    const cpuStarted = process.cpuUsage()
+    const measured = await this.runScenario()
+    const totalMs = performance.now() - started
+    const cpu = process.cpuUsage(cpuStarted)
+    if (this.retained === undefined) throw new Error(`${this.scenario} did not retain its measured endpoint`)
+    const afterGc = await collectGarbage()
+    return {
+      scenario: this.scenario,
+      totalMs,
+      ...measured.phases === undefined ? {} : { phases: measured.phases },
+      cpuUserMs: cpu.user / 1_000,
+      cpuSystemMs: cpu.system / 1_000,
+      events: measured.events,
+      beforeGc,
+      afterGc,
+      retained: memoryDelta(beforeGc, afterGc),
+    }
+  }
+
+  async dispose(): Promise<void> {
+    this.historyAbort?.abort(new Error('Session opening benchmark complete'))
+    await this.historyIterator?.return?.()
+    await this.agentHandle?.dispose()
+    this.preparation?.[Symbol.dispose]()
+    this.retained = undefined
+    await this.ctx.fiber.dispose()
+  }
+
+  private runScenario(): Promise<{
+    readonly events: number
+    readonly phases?: SessionOpenWorkerReport['phases']
+  }> {
+    switch (this.scenario) {
+      case 'phase-migrate':
+      case 'phase-steady':
+        return this.measurePhases()
+      case 'first-history':
+        return this.measureFirstHistory()
+      case 'agent-resume':
+        return this.measureAgentResume()
+    }
+  }
+
+  private async measurePhases(): Promise<{
+    readonly events: number
+    readonly phases: NonNullable<SessionOpenWorkerReport['phases']>
+  }> {
+    let phaseStarted = performance.now()
+    const handle = await this.ctx.sessionPersistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
+    const openMs = performance.now() - phaseStarted
+    phaseStarted = performance.now()
+    const persisted = await handle.read()
+    await handle.close()
+    const readMs = performance.now() - phaseStarted
+    phaseStarted = performance.now()
+    const repaired = [...persisted, ...interruptedTurnClosers(persisted)]
+    const seed = repaired.map(event => structuredClone(event))
+    const preparation = SessionPreparation.create(this.ctx.sessions.prepare(SessionId(SYNTHETIC_SESSION_ID), {
+      seed,
+      meta: structuredClone(handle.header),
+      inheritedEventCount: handle.inheritedEventCount,
+      seedSource: 'persistence',
+    }))
+    this.preparation = preparation
+    const sessionRestoreMs = performance.now() - phaseStarted
+    phaseStarted = performance.now()
+    const projection = this.ctx.sessionProjections.hydrate(
+      preparation.session,
+      {},
+      seed,
+      SessionLogOffset(0),
+    )
+    const projectionMs = performance.now() - phaseStarted
+    this.retained = { preparation, projection, seed }
+    return {
+      events: preparation.session.seq,
+      phases: { openMs, readMs, sessionRestoreMs, projectionMs },
+    }
+  }
+
+  private async measureFirstHistory(): Promise<{ readonly events: number }> {
+    const abort = new AbortController()
+    this.historyAbort = abort
+    const history = new SessionHistoryController(this.ctx, (observation) => {
+      observation[Symbol.dispose]()
+    })
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: SessionId(SYNTHETIC_SESSION_ID) },
+    }, abort.signal)[Symbol.asyncIterator]()
+    this.historyIterator = iterator as AsyncIterator<unknown>
+    const first = await iterator.next()
+    if (first.done || first.value.type !== 'snapshot') {
+      throw new Error('Session history did not produce an opening snapshot')
+    }
+    this.retained = { history, iterator, first }
+    return { events: first.value.records.length }
+  }
+
+  private async measureAgentResume(): Promise<{ readonly events: number }> {
+    const handle = await this.ctx.agents.resume({
+      resumeSessionId: SessionId(SYNTHETIC_SESSION_ID),
+      agentOptions: { provider: 'bench', model: 'bench' },
+    })
+    this.agentHandle = handle
+    this.retained = handle
+    return { events: handle.agent.session.seq }
+  }
+}
+
+const [root, scenarioValue] = process.argv.slice(2)
+const scenarios: readonly SessionOpenBenchmarkScenario[] = [
+  'phase-migrate',
+  'phase-steady',
+  'first-history',
+  'agent-resume',
+]
+if (root === undefined || !scenarios.includes(scenarioValue as SessionOpenBenchmarkScenario)) {
+  throw new Error('usage: session-open.bench.worker.ts <root> <phase-migrate|phase-steady|first-history|agent-resume>')
+}
+const scenario = scenarioValue as SessionOpenBenchmarkScenario
+const host = await SessionBenchmarkHost.create(root, scenario)
+let report: SessionOpenWorkerReport
+try {
+  report = await host.measure()
+} finally {
+  await host.dispose()
+}
+process.stdout.write(`${JSON.stringify(report)}\n`)

+ 58 - 45
packages/session/session-persistence-jsonl/tests/synthetic-released-v0-log.ts → benchmarks/session-open/synthetic-released-v0-session.ts

@@ -1,30 +1,29 @@
-/**
- * Deterministic released-v0 Session log synthesized from fixed parameters.
- * The content is generated in-process (numbered prompts, counters, and
- * repeated tokens) so the benchmark input carries no recorded material.
- */
+/** Deterministic released-v0 Zstandard Session input for opening benchmarks. */
 
 import { mkdir, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { releasedV0SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0-to-v1'
 import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
+import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts'
 
-/** Fixed workload parameters; every count below is derived from them. */
-export interface SyntheticV0LogShape {
+/** Fixed workload parameters used by every Session-opening scenario. */
+export interface SyntheticV0SessionShape {
   /** Completed turns, each with one user prompt and one streamed assistant reply. */
   readonly turns: number
-  /** `text-delta` chunks per reply; the reply also streams `textDeltas / 4` reasoning deltas. */
+  /** Text deltas per reply; each reply also contains one quarter as many reasoning deltas. */
   readonly textDeltas: number
 }
 
-/** Session id and cwd used by every synthesized log. */
+/** Stable identity and storage location of the synthesized Session. */
 export const SYNTHETIC_SESSION_ID = 'bench-session'
 export const SYNTHETIC_SESSION_CWD = '/bench'
-
-/** Physical directory of the synthesized log below one JSONL root (project slug + session segment). */
 export const SYNTHETIC_SESSION_DIRECTORY = join('--bench--', SYNTHETIC_SESSION_ID)
+export const SYNTHETIC_V0_FILENAME = 'session.jsonl.zstd'
+export const SYNTHETIC_CURRENT_FILENAME = 'session.v2.jsonl.zstd'
 
 const TIME_ZERO = 1_700_000_000_000
+/** One body frame per row preserves the historical many-frame workload deterministically. */
+const ROWS_PER_FRAME = 1
 
 interface SyntheticEvent {
   readonly type: string
@@ -35,12 +34,17 @@ interface SyntheticEvent {
   readonly surfaceOp?: 'append'
 }
 
-/**
- * Build the logical released-v0 events for one shape.
- * @param shape - fixed workload parameters.
- * @returns dense events in log order.
- */
-export function synthesizeReleasedV0Events(shape: SyntheticV0LogShape): readonly SyntheticEvent[] {
+/** Complete metadata returned after writing one synthetic source generation. */
+export interface SyntheticV0SessionWrite {
+  readonly path: string
+  readonly compressedBytes: number
+  readonly logicalBytes: number
+  readonly events: number
+  readonly rows: number
+  readonly frames: number
+}
+
+function synthesizeEvents(shape: SyntheticV0SessionShape): readonly SyntheticEvent[] {
   const events: SyntheticEvent[] = []
   let seq = 0
   let time = TIME_ZERO
@@ -54,9 +58,9 @@ export function synthesizeReleasedV0Events(shape: SyntheticV0LogShape): readonly
   for (let turn = 1; turn <= shape.turns; turn += 1) {
     push('turn/start', { turn })
     push('user/message', {
-      id: `user-${turn}`,
+      id: `user-${String(turn)}`,
       role: 'user',
-      content: [{ type: 'text', text: `prompt ${turn}` }],
+      content: [{ type: 'text', text: `prompt ${String(turn)}` }],
       source: { kind: 'user' },
     }, { surfaceOp: 'append' })
     push('step/start', { turn, step: 1 })
@@ -67,7 +71,7 @@ export function synthesizeReleasedV0Events(shape: SyntheticV0LogShape): readonly
     chunk({ type: 'block-start', index: 0, blockType: 'reasoning' })
     let reasoning = ''
     for (let index = 0; index < reasoningDeltas; index += 1) {
-      const delta = `r${index} `
+      const delta = `r${String(index)} `
       reasoning += delta
       chunk({ type: 'reasoning-delta', index: 0, text: delta })
     }
@@ -75,7 +79,7 @@ export function synthesizeReleasedV0Events(shape: SyntheticV0LogShape): readonly
     chunk({ type: 'block-start', index: 1, blockType: 'text' })
     let text = ''
     for (let index = 0; index < shape.textDeltas; index += 1) {
-      const delta = `w${index} `
+      const delta = `w${String(index)} `
       text += delta
       chunk({ type: 'text-delta', index: 1, text: delta })
     }
@@ -87,7 +91,7 @@ export function synthesizeReleasedV0Events(shape: SyntheticV0LogShape): readonly
       turn,
       step: 1,
       message: {
-        id: `assistant-${turn}`,
+        id: `assistant-${String(turn)}`,
         role: 'assistant',
         content: [{ type: 'reasoning', text: reasoning }, { type: 'text', text }],
         source: { kind: 'model', provider: 'bench', model: 'bench' },
@@ -101,12 +105,16 @@ export function synthesizeReleasedV0Events(shape: SyntheticV0LogShape): readonly
 }
 
 /**
- * Encode one shape as the released-v0 physical JSONL text (packed chunk rows).
- * @param shape - fixed workload parameters.
- * @returns the complete file text plus the logical event count.
+ * Write one deterministic released-v0 Zstandard generation.
+ * @param root - JSONL persistence root.
+ * @param shape - workload size.
+ * @returns physical and logical workload facts.
  */
-export function synthesizeReleasedV0LogText(shape: SyntheticV0LogShape): { readonly text: string; readonly events: number } {
-  const events = synthesizeReleasedV0Events(shape)
+export async function writeSyntheticReleasedV0Session(
+  root: string,
+  shape: SyntheticV0SessionShape,
+): Promise<SyntheticV0SessionWrite> {
+  const events = synthesizeEvents(shape)
   const header = {
     version: 0,
     id: SYNTHETIC_SESSION_ID,
@@ -119,24 +127,29 @@ export function synthesizeReleasedV0LogText(shape: SyntheticV0LogShape): { reado
     { header, inheritedEventCount: 0, events: events as unknown as readonly SessionFormatEvent[] },
     { packChunks: true },
   )
-  const lines = [JSON.stringify(encoded.header), ...encoded.rows.map(row => JSON.stringify(row))]
-  return { text: `${lines.join('\n')}\n`, events: events.length }
-}
-
-/**
- * Write the synthesized raw v0 log where the JSONL backend expects it.
- * @param root - JSONL persistence root directory.
- * @param shape - fixed workload parameters.
- * @returns the written path, byte length, and logical event count.
- */
-export async function writeSyntheticReleasedV0Log(
-  root: string,
-  shape: SyntheticV0LogShape,
-): Promise<{ readonly path: string; readonly bytes: number; readonly events: number }> {
-  const { text, events } = synthesizeReleasedV0LogText(shape)
+  const headerLine = `${JSON.stringify(encoded.header)}\n`
+  const bodyFrames: Buffer[] = []
+  let logicalBytes = Buffer.byteLength(headerLine)
+  for (let index = 0; index < encoded.rows.length; index += ROWS_PER_FRAME) {
+    const rows = encoded.rows.slice(index, index + ROWS_PER_FRAME)
+    const text = `${rows.map(row => JSON.stringify(row)).join('\n')}\n`
+    logicalBytes += Buffer.byteLength(text)
+    bodyFrames.push(await compressZstdFrame(text))
+  }
+  const physical = Buffer.concat([
+    await compressZstdFrame(headerLine),
+    ...bodyFrames,
+  ])
   const directory = join(root, SYNTHETIC_SESSION_DIRECTORY)
   await mkdir(directory, { recursive: true })
-  const path = join(directory, 'session.jsonl')
-  await writeFile(path, text)
-  return { path, bytes: Buffer.byteLength(text), events }
+  const path = join(directory, SYNTHETIC_V0_FILENAME)
+  await writeFile(path, physical)
+  return {
+    path,
+    compressedBytes: physical.byteLength,
+    logicalBytes,
+    events: events.length,
+    rows: encoded.rows.length,
+    frames: encoded.rows.length + 1,
+  }
 }

+ 2 - 2
docs/testing.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/testing.md
-testing.md: 3b9e22278821bff6c47ff4291223c6738e0e8fe7
-testing.zh.md: 2674eef23b9eab07f0a09aae688e68dd7ea9c32a
+testing.md: 06abd29971bcf6918373e8d09681490cf743d7cd
+testing.zh.md: 1aa1270f29562c08c73cee141dd6b98c953f752b

+ 1 - 1
docs/testing.md

@@ -10,7 +10,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
 - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate flags for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/shell/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar.
 - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)).
 - **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`.
-- **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `*.bench.ts` and Client-face `*.bench.client.ts` files under `packages/*/*/tests/` synthesize input from fixed parameters, never recorded material, and fail on a documented wall-clock budget, heap limit, or scaling ratio ([rules and current gates](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)).
+- **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): top-level `benchmarks/` holds `*.bench.ts` and Client-face `*.bench.client.ts` gates grouped by user path. They synthesize fixed inputs, never recordings, and enforce documented wall-clock, heap, or scaling budgets; package-local `.perf.ts` remains diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)).
 - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff.
 - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS.
 

+ 1 - 1
docs/testing.zh.md

@@ -10,7 +10,7 @@
 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/shell/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其执行器套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。
 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。
 - **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。
-- **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`packages/*/*/tests/` 下的 `*.bench.ts` 与 Client 面 `*.bench.client.ts` 文件按固定参数合成输入,绝不含录制材料,并在超出已记录的壁钟预算、堆限制或缩放比时失败([规则与当前 gate](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。
+- **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):顶层 `benchmarks/` 按用户路径组织 `*.bench.ts` 与 Client 面 `*.bench.client.ts` gate。它们使用固定合成输入而非录制材料,并执行有记录的壁钟、堆或缩放预算;包内 `.perf.ts` 仍是诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。
 - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。
 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。
 

+ 0 - 153
packages/session/session-persistence-jsonl/tests/open-generation.bench.ts

@@ -1,153 +0,0 @@
-/**
- * Performance gate for opening a large released-v0 Session log through the
- * JSONL backend: the first `open()` migrates and publishes the current
- * generation; later opens decode the published generation. Both run in child
- * processes under a fixed heap limit so an allocation regression fails as an
- * out-of-memory exit instead of passing on a machine with more memory.
- */
-
-import { spawn } from 'node:child_process'
-import { copyFile, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import type { OpenGenerationWorkerReport } from './open-generation.bench.worker.ts'
-import { SYNTHETIC_SESSION_DIRECTORY, writeSyntheticReleasedV0Log } from './synthetic-released-v0-log.ts'
-
-/** 200 turns × (500 text + 125 reasoning deltas): 127,400 released-v0 events in about 2.8 MB of JSONL. */
-const SHAPE = { turns: 200, textDeltas: 500 } as const
-
-/**
- * Wall-clock budget for the migrating first `open()`. The pre-stack backend
- * decoded the same bytes in about 35 ms on the reference machine; a whole
- * artifact migration that validates, transforms, publishes, and re-reads the
- * log under the heap limit below costs about 1 s there and about 2 s on the
- * CI runner. The budget doubles the CI cost while staying far below the ~5 s
- * (~10 s on CI) that the repeated-snapshot implementation needed.
- */
-const MIGRATION_BUDGET_MS = 4_000
-
-/**
- * Old-space limit for the migrating child process. Pre-stack decoding of the
- * same log completed under 128 MB; the repeated-snapshot migration exhausted
- * that heap. Holding the limit fixed keeps the gate independent of the
- * runner's physical memory.
- */
-const MIGRATION_HEAP_LIMIT_MB = 128
-
-/** Wall-clock budget for a fresh process opening the already published current generation. */
-const STEADY_OPEN_BUDGET_MS = 500
-
-/** Attempts per measurement; the gate compares the minimum so scheduler noise only adds. */
-const ATTEMPTS = 3
-
-const WORKER = join(import.meta.dirname, 'open-generation.bench.worker.ts')
-
-interface WorkerRun {
-  readonly report: OpenGenerationWorkerReport | undefined
-  readonly exitCode: number | null
-  readonly stderr: string
-}
-
-function runWorker(root: string, mode: 'migrate' | 'steady', heapLimitMb: number): Promise<WorkerRun> {
-  return new Promise((resolve, reject) => {
-    const child = spawn(process.execPath, [
-      `--max-old-space-size=${String(heapLimitMb)}`,
-      '--import',
-      'tsx/esm',
-      WORKER,
-      root,
-      mode,
-    ], { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] })
-    let stdout = ''
-    let stderr = ''
-    child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk })
-    child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
-    child.once('error', reject)
-    child.once('close', (exitCode) => {
-      const line = stdout.trim().split('\n').at(-1)
-      let report: OpenGenerationWorkerReport | undefined
-      if (exitCode === 0 && line !== undefined && line.startsWith('{')) {
-        report = JSON.parse(line) as OpenGenerationWorkerReport
-      }
-      resolve({ report, exitCode, stderr })
-    })
-  })
-}
-
-function requireReport(run: WorkerRun, label: string): OpenGenerationWorkerReport {
-  if (run.report === undefined) {
-    const lines = run.stderr.trim().split('\n')
-    const fatal = lines.filter(line => /FATAL ERROR|heap limit|out of memory/i.test(line))
-    const detail = (fatal.length > 0 ? fatal : lines.slice(-8)).join('\n')
-    throw new Error(`${label} exited with ${String(run.exitCode)} under --max-old-space-size=${String(MIGRATION_HEAP_LIMIT_MB)}:\n${detail}`)
-  }
-  return run.report
-}
-
-describe('opening a large released-v0 Session log', () => {
-  let scratch: string
-  let sourcePath: string
-  let sourceBytes = 0
-  let sourceEvents = 0
-  const migratedRoots: string[] = []
-
-  beforeAll(async () => {
-    scratch = await mkdtemp(join(tmpdir(), 'dsh-open-generation-bench-'))
-    const written = await writeSyntheticReleasedV0Log(join(scratch, 'source'), SHAPE)
-    sourcePath = written.path
-    sourceBytes = written.bytes
-    sourceEvents = written.events
-  })
-
-  afterAll(async () => {
-    await rm(scratch, { recursive: true, force: true })
-  })
-
-  it(`migrates ${String(SHAPE.turns)} turns of streamed replies within ${String(MIGRATION_BUDGET_MS)} ms under a ${String(MIGRATION_HEAP_LIMIT_MB)} MB heap`, async () => {
-    const reports: OpenGenerationWorkerReport[] = []
-    for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
-      const root = join(scratch, `migrate-${String(attempt)}`)
-      await mkdir(join(root, SYNTHETIC_SESSION_DIRECTORY), { recursive: true })
-      await copyFile(sourcePath, join(root, SYNTHETIC_SESSION_DIRECTORY, 'session.jsonl'))
-      reports.push(requireReport(await runWorker(root, 'migrate', MIGRATION_HEAP_LIMIT_MB), `migration attempt ${String(attempt)}`))
-      migratedRoots.push(root)
-      const files = (await readdir(join(root, SYNTHETIC_SESSION_DIRECTORY))).sort()
-      expect(files).toEqual(['session.jsonl', 'session.v2.jsonl'])
-    }
-    const openMs = Math.min(...reports.map(report => report.openMs))
-    const parseMs = Math.min(...reports.map(report => report.parseMs))
-    console.log(JSON.stringify({
-      benchmark: 'open-generation/migrate',
-      sourceBytes,
-      sourceEvents,
-      currentEvents: reports[0]?.events,
-      openMs: Math.round(openMs),
-      parseMs: Math.round(parseMs),
-      readMs: Math.round(Math.min(...reports.map(report => report.readMs))),
-      heapUsedMb: Math.round(Math.max(...reports.map(report => report.heapUsedMb))),
-      heapLimitMb: MIGRATION_HEAP_LIMIT_MB,
-      budgetMs: MIGRATION_BUDGET_MS,
-    }))
-    expect(reports.every(report => report.headerVersion === 2)).toBe(true)
-    expect(openMs).toBeLessThanOrEqual(MIGRATION_BUDGET_MS)
-  })
-
-  it(`opens the published current generation within ${String(STEADY_OPEN_BUDGET_MS)} ms`, async () => {
-    expect(migratedRoots.length, 'a published current generation from the migration benchmark').toBeGreaterThan(0)
-    const reports: OpenGenerationWorkerReport[] = []
-    for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
-      const root = migratedRoots[attempt % migratedRoots.length] as string
-      reports.push(requireReport(await runWorker(root, 'steady', MIGRATION_HEAP_LIMIT_MB), `steady attempt ${String(attempt)}`))
-    }
-    const openMs = Math.min(...reports.map(report => report.openMs))
-    console.log(JSON.stringify({
-      benchmark: 'open-generation/steady',
-      openMs: Math.round(openMs),
-      readMs: Math.round(Math.min(...reports.map(report => report.readMs))),
-      heapUsedMb: Math.round(Math.max(...reports.map(report => report.heapUsedMb))),
-      budgetMs: STEADY_OPEN_BUDGET_MS,
-    }))
-    expect(openMs).toBeLessThanOrEqual(STEADY_OPEN_BUDGET_MS)
-  })
-})

+ 0 - 63
packages/session/session-persistence-jsonl/tests/open-generation.bench.worker.ts

@@ -1,63 +0,0 @@
-/**
- * Child-process worker for the open-generation benchmark: opens one Session
- * through the JSONL backend under the caller's heap limit and reports timings
- * as one JSON line. Arguments: `<root> <mode>` where mode is `migrate`
- * (release-v0 source only) or `steady` (published current generation).
- */
-
-import { Context } from '@deepseek-ai/cordis'
-import { readFile } from 'node:fs/promises'
-import { join } from 'node:path'
-import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
-import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
-import { SYNTHETIC_SESSION_DIRECTORY, SYNTHETIC_SESSION_ID } from './synthetic-released-v0-log.ts'
-
-/** Timings printed by the worker. */
-export interface OpenGenerationWorkerReport {
-  readonly mode: 'migrate' | 'steady'
-  /** `open()` wall time; for `migrate` this includes publishing the current generation. */
-  readonly openMs: number
-  /** `read()` of the complete current event list after `open()`. */
-  readonly readMs: number
-  /** `JSON.parse` of every source line, as the pure parsing floor of the same bytes. */
-  readonly parseMs: number
-  readonly events: number
-  readonly headerVersion: number
-  readonly heapUsedMb: number
-}
-
-const [root, mode] = process.argv.slice(2)
-if (root === undefined || (mode !== 'migrate' && mode !== 'steady')) {
-  throw new Error('usage: open-generation.bench.worker.ts <root> migrate|steady')
-}
-
-const sourceText = await readFile(join(root, SYNTHETIC_SESSION_DIRECTORY, 'session.jsonl'), 'utf8')
-const parseStarted = performance.now()
-for (const line of sourceText.split('\n')) {
-  if (line.length > 0) JSON.parse(line)
-}
-const parseMs = performance.now() - parseStarted
-
-const ctx = new Context()
-await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
-const openStarted = performance.now()
-const handle = await ctx.sessionPersistence.open(SessionId(SYNTHETIC_SESSION_ID), 'read')
-const openMs = performance.now() - openStarted
-const readStarted = performance.now()
-const events = await handle.read()
-const readMs = performance.now() - readStarted
-await handle.close()
-if (handle.header.version !== SESSION_FORMAT_VERSION) {
-  throw new Error(`expected current format v${SESSION_FORMAT_VERSION}, opened v${handle.header.version}`)
-}
-const report: OpenGenerationWorkerReport = {
-  mode,
-  openMs,
-  readMs,
-  parseMs,
-  events: events.length,
-  headerVersion: handle.header.version,
-  heapUsedMb: process.memoryUsage().heapUsed / 1_048_576,
-}
-process.stdout.write(`${JSON.stringify(report)}\n`)
-process.exit(0)

+ 3 - 1
tsconfig.client.json

@@ -1,5 +1,5 @@
 {
-  // Client-side typecheck aggregate: packages/client tests (.ts and .tsx).
+  // Client-side typecheck aggregate: packages/client tests and top-level Client benchmarks.
   // Split from the host aggregate because both sides merge cordis Context
   // under the same keys (sessions, loader) with different services; shared
   // leaves (session/llm/tools/...) build once and are referenced by
@@ -19,6 +19,8 @@
     "packages/client/*/src/css-modules.d.ts",
     "packages/client/*/tests/**/*.ts",
     "packages/client/*/tests/**/*.tsx",
+    "benchmarks/**/*.client.ts",
+    "benchmarks/**/*.client.tsx",
     "packages/*/*/tests/**/*.client.spec.ts",
     "packages/*/*/tests/**/*.client.spec.tsx",
     "packages/*/*/tests/**/*.client.tsx",

+ 5 - 2
tsconfig.host.json

@@ -99,17 +99,20 @@
     "apps/web/tests/workflow-run.e2e.ts",
     "apps/web/stress-tests/reasoning-chunks.stress.ts",
     "apps/cli/tests/**/*.ts",
+    "benchmarks/**/*.ts",
     "packages/*/*/tests/**/*.ts",
     "scripts/**/*.ts",
     "website/**/*.ts",
     "website/.vitepress/**/*.ts"
   ],
-  // Under packages/client a test file names the face it covers: `*.client.*`
-  // belongs to the Client aggregate, `*.host.spec.ts` to this one. The two
+  // Under packages/client and benchmarks, a test file names the face it covers:
+  // `*.client.*` belongs to the Client aggregate, `*.host.spec.ts` to this one. The two
   // suffixes are mutually exclusive, so each aggregate excludes the other's
   // and the package test glob above needs no per-file entry.
   "exclude": [
     "packages/client/*/src/**",
+    "benchmarks/**/*.client.ts",
+    "benchmarks/**/*.client.tsx",
     "packages/*/*/tests/**/*.client.ts",
     "packages/*/*/tests/**/*.client.tsx",
     "packages/*/*/tests/**/*.client.spec.ts",

+ 4 - 4
vitest.bench.config.ts

@@ -3,8 +3,8 @@ import { defineConfig } from 'vitest/config'
 import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts'
 
 /**
- * CI performance gate. Every `*.bench.ts` file synthesizes its own input from
- * fixed parameters, measures one owner-visible path, and fails when a
+ * CI performance gate. Every benchmark under `benchmarks/` synthesizes its
+ * own input from fixed parameters, measures one user-visible path, and fails when a
  * documented time or heap budget is exceeded. Files run one at a time so a
  * measurement never shares the CPU with another benchmark.
  */
@@ -14,8 +14,8 @@ export default defineConfig({
     execArgv: vitestExecArgv,
     setupFiles: ['./scripts/test-proxy-environment.ts'],
     include: [
-      'packages/*/*/tests/**/*.bench.ts',
-      'packages/*/*/tests/**/*.bench.client.ts',
+      'benchmarks/**/*.bench.ts',
+      'benchmarks/**/*.bench.client.ts',
     ],
     fileParallelism: false,
     maxWorkers: 1,