Ver código fonte

Merge remote-tracking branch 'origin/master' into codex/retire-subagent-mock

# Conflicts:
#	docs/rfc/INDEX.md
Tianyi Cui 2 meses atrás
pai
commit
0488ec3d3d

+ 4 - 4
docs/cordis-catalog/events.md

@@ -257,7 +257,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
 'session/created'(this: Scoped<Session>, session: Session): void
 ```
 
-Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts)
 
 ### `session/disposed` — emit
 
@@ -267,7 +267,7 @@ Emitted once when an announced session leaves the store, including publication r
 'session/disposed'(this: Scoped<Session>, session: Session): void
 ```
 
-Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts)
 
 ### `session/event` — emit
 
@@ -279,7 +279,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
 
 Types: [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts)
 
 ### `session/flush` — parallel
 
@@ -289,7 +289,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
 ```
 
-Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
 
 ## `subagent/*`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -224,7 +224,7 @@ list(): Session[]
 fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
 ```
 
-Source: [`packages/core/session/src/index.ts:585`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/src/index.ts)
 
 ## `ctx.skills` — `SkillService`
 

+ 12 - 1
docs/core-data-structures/session.md

@@ -194,9 +194,20 @@ Required for `SurfaceEventType` events — every message-producing event must de
 
 The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs`; omission does not assert that its source stream was empty.
 
+### `SessionSurface` — the live readonly surface projection
+
+`Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation.
+
+```ts type-equiv
+export interface SessionSurface {
+  readonly nodes: readonly number[]
+  readonly replaceGeneration: number
+}
+```
+
 ### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
 
-`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. `SurfaceManager` uses the same transitions for its incremental cache without retaining replacement history. Its `replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite.
+`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite.
 
 ```ts type-equiv
 export interface SurfaceFoldReplacement {

+ 4 - 4
docs/event-producer-consumer.md

@@ -26,10 +26,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:40`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 1 - 1
docs/rfc/INDEX.md

@@ -22,7 +22,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 |---|---|
 | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
 | [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 |
-| [Use one surface manager per session](proposed/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 |
 
 ### Architecture
 
@@ -117,6 +116,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
 | [Simplify session-log representation](implemented/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 |
 | [Retire the standalone subagent mock package](implemented/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 |
+| [Use one surface manager per session](implemented/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 |
 
 ### Architecture
 

+ 2 - 2
docs/rfc/implemented/architecture/2026-06-18-session-surface.md

@@ -31,7 +31,7 @@ export type SurfaceOp =
 
 ### SurfaceManager: delta-based, not full rebuild
 
-A `SurfaceManager` class (private to `Session`) maintains one ordered `number[]` of event seqs. It tracks `_lastProcessedSeq` and processes only the new events since the last access rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial suffix folded on first access. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no link objects or seq-to-node map duplicate the order.
+A `Session` owns one `SurfaceManager` that maintains an ordered `number[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order.
 
 Delta processing is O(1) when no new events and O(new events) when new events arrive.
 
@@ -60,7 +60,7 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d
 
 ## Consequences
 
-- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array; `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
+- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
 - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance.
 - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration).
 - **`packages/support/invariants`**: Surface-related validation rules.

+ 2 - 2
docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml → docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.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
-2026-07-19-use-one-session-surface-manager.md: 0e4fa14b21f5054e1ace425712506c5c060f2251
-2026-07-19-use-one-session-surface-manager.zh.md: 26126310eefaf56aa45efc428a54e65f0cf35947
+2026-07-19-use-one-session-surface-manager.md: 0c0fd70717ff4b49e8817e88592d0781d9ccbd09
+2026-07-19-use-one-session-surface-manager.zh.md: 6d11c9bb475b0ee5a568f96678d0251f1160dda3

+ 37 - 0
docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md

@@ -0,0 +1,37 @@
+# RFC: Use one surface manager per session
+
+Status: implemented
+
+English | [中文](2026-07-19-use-one-session-surface-manager.zh.md)
+
+## Problem
+
+`Session` maintained two `SurfaceManager` instances over the same append-only event log. One validated seed and append candidates, while a second lazy instance independently folded committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface had been read, every later event advanced duplicate node and replacement-generation state without creating a separate authority or failure boundary.
+
+## Decision
+
+Each `Session` owns one eagerly constructed `SurfaceManager`. Seed and append acceptance call `validateNext()` on that manager before committing an event, and `session.surface` returns the same object through this readonly contract:
+
+```ts
+export interface SessionSurface {
+  readonly nodes: readonly number[]
+  readonly replaceGeneration: number
+}
+```
+
+Candidate validation remains atomic. `validateNext()` may synchronize committed log entries, but it only plans the uncommitted candidate. The candidate enters manager state after `log.push()` and the next delta synchronization, so surface validation failures and pre-commit `internal/dispatch` vetoes leave no phantom node or replacement generation.
+
+`foldSurface()` remains the detached full-log replay function for offline validation and reconstruction. It uses the same transitions and agrees with the live manager for every committed prefix without sharing mutable state.
+
+## Alternatives considered
+
+**Keep acceptance and projection state separate.** Separate instances appeared to isolate public reads from validation, but callers already receive borrowed surface state and the declared readonly contract prevents ordinary mutation. Duplicating the manager was not a runtime trust boundary.
+
+**Recompute the public surface from the full log on every access.** This removed duplicate cached state but gave up incremental derivation and made repeated request construction scale with complete session history.
+
+## Consequences
+
+- Acceptance, `session.surface`, derived messages, compaction, and workspace context observe one incremental state.
+- `Session.surface` exposes no validation method, while its object identity and borrowed readonly node array remain stable.
+- A hostile cast can still corrupt borrowed state; JavaScript callers that deliberately bypass the readonly contract remain outside the supported same-process boundary.
+- Surface, seed, dispatch-veto, request-reconstruction, compaction, and workspace-context tests exercise the shared manager and detached replay paths.

+ 37 - 0
docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md

@@ -0,0 +1,37 @@
+# RFC: 每个会话只使用一个表层管理器
+
+Status: implemented
+
+[English](2026-07-19-use-one-session-surface-manager.md) | 中文
+
+## 问题
+
+`Session` 曾针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。一个实例负责校验种子事件和追加候选事件,另一个延迟创建的实例则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态,却没有形成独立真源或失败边界。
+
+## 决策
+
+每个 `Session` 主动创建并只持有一个 `SurfaceManager`。种子事件与追加事件的接纳流程在提交事件之前调用该管理器的 `validateNext()`,`session.surface` 则通过以下只读契约返回同一个对象:
+
+```ts
+export interface SessionSurface {
+  readonly nodes: readonly number[]
+  readonly replaceGeneration: number
+}
+```
+
+候选事件校验仍保持原子性。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验失败或提交前 `internal/dispatch` 否决都不会留下虚假节点或替换代数。
+
+`foldSurface()` 仍是离线校验与重建使用的分离式完整日志回放函数。它使用相同的状态转换,并且对每个已提交前缀都与活跃管理器一致,但不共享可变状态。
+
+## 备选方案
+
+**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但调用方取得的本来就是借用的表层状态,声明的只读契约会阻止普通修改。复制管理器并不能构成运行时信任边界。
+
+**每次读取都根据完整日志重新计算公共表层。** 该方案能消除重复缓存状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。
+
+## 影响
+
+- 接纳流程、`session.surface`、派生消息、压缩和工作区上下文观察同一份增量状态。
+- `Session.surface` 不暴露校验方法,同时保持对象标识和借用的只读节点数组稳定。
+- 恶意类型断言仍可破坏借用状态;刻意绕过只读契约的 JavaScript 调用方不属于受支持的同进程边界。
+- 表层、种子、调度否决、请求重建、压缩和工作区上下文测试覆盖共享管理器与分离回放路径。

+ 0 - 43
docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md

@@ -1,43 +0,0 @@
-# RFC: Use one surface manager per session
-
-Status: proposed
-
-English | [中文](2026-07-19-use-one-session-surface-manager.zh.md)
-
-## Problem
-
-`Session` maintains two `SurfaceManager` instances over the same append-only event log. `surfaceValidator` eagerly validates seed and append candidates, while the lazy `_surface` independently folds committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface is read, every later event advances duplicate node and replacement-generation state.
-
-The [session surface decision](../../implemented/architecture/2026-06-18-session-surface.md) calls for one ordered surface and one representation to validate. The second manager does not create an independent authority or protect a different failure boundary; it repeats the canonical fold and gives the two views a state-drift opportunity.
-
-## Proposal
-
-Keep one `SurfaceManager` per `Session`. Seed and append acceptance continue to call `validateNext()` before committing an event, and the public surface view reads `nodes` and `replaceGeneration` from that same manager.
-
-Expose only the readonly surface contract from `Session.surface`; candidate validation remains owned by `Session`. Retain `foldSurface()` as the detached full-log replay function used by offline validation and reconstruction.
-
-## Implementation plan
-
-1. In `packages/core/session/src/surface.ts`, export a structural `SessionSurface` contract containing only readonly `nodes` and `replaceGeneration`, and make `SurfaceManager` implement it. Re-export that type from `packages/core/session/src/index.ts` so `Session.surface` no longer exposes `validateNext()` through its declaration.
-2. In `Session`, replace `surfaceValidator` and lazy `_surface` with one eagerly constructed `surfaceManager`. Route seed and append validation through that manager and return it from `get surface(): SessionSurface`; `deriveMessages()` will read the same nodes and generation. `validateNext()` may synchronize already committed log entries, but it must only plan the uncommitted candidate. The candidate reaches manager state after `log.push()` and the next delta synchronization, so rejection by surface validation or pre-commit `internal/dispatch` cannot leave phantom state.
-3. Keep `foldSurface()` and the transition functions in `surface.ts` unchanged. Compile and exercise the direct consumers in `packages/compact/compact/src/tool-pairing.ts`, `packages/compact/compact-basic/src/region.ts`, and `packages/context/workspace-context/src/state.ts`; they continue to consume only nodes and replacement generation.
-4. Extend `packages/core/session/tests/surface.spec.ts` to read the public view before an invalid candidate, prove that nodes and generation remain at the accepted prefix after rejection, append a later valid event, and compare every resulting prefix with `foldSurface()`. Add an `internal/dispatch` veto case and a type-level `SessionSurface` assertion in `session.spec.ts`, while retaining the seeded replay, delta-growth, replacement, generation, and derived-cache cases.
-5. Run the request-reconstruction, compaction tool-pairing, compaction range, and workspace-context regression suites that consume the surface. In the implementation PR, update `packages/core/session/README.md`, `docs/core-data-structures/session.md`, the implemented session-surface RFC and its Chinese counterpart, the translation record, `scripts/type-equiv.manifest.json`, and the generated RFC index before moving this RFC pair to `implemented/`.
-
-## Alternatives considered
-
-**Keep acceptance and projection state separate.** Separate instances appear to isolate public reads from validation, but ordinary callers already receive borrowed surface state and cannot mutate it through the declared readonly contract. A cast that mutates the returned node array already corrupts derived history; duplicating the manager is not a sound runtime trust boundary.
-
-**Recompute the public surface from the full log on every access.** This removes cached duplicate state but gives up incremental derivation and makes repeated request construction scale with complete session history.
-
-## Acceptance criteria
-
-- A live `Session` owns exactly one incremental `SurfaceManager`.
-- Seed and append candidates are validated before publication with no partial surface mutation on rejection.
-- `session.surface`, derived messages, compaction, and workspace context observe the same nodes and replacement generation as the acceptance path.
-- `foldSurface()` remains available for detached replay and agrees with the live manager for every accepted prefix.
-- Session surface, seed, request reconstruction, compaction tool-pairing, and workspace-context tests pass.
-
-## Risks
-
-Sharing one manager makes the readonly borrowed-state contract more important because a hostile cast could corrupt both validation and projection state. The implementation should return a narrowed view and keep mutation methods inaccessible through `Session.surface`; JavaScript callers that deliberately bypass the type contract remain outside the supported same-process boundary.

+ 0 - 43
docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md

@@ -1,43 +0,0 @@
-# RFC: 每个会话只使用一个表层管理器
-
-Status: proposed
-
-[English](2026-07-19-use-one-session-surface-manager.md) | 中文
-
-## 问题
-
-`Session` 针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。`surfaceValidator` 主动校验种子事件与追加候选事件,延迟创建的 `_surface` 则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态。
-
-[会话表层决策](../../implemented/architecture/2026-06-18-session-surface.md)要求系统只保留一个有序表层,并使用一种表示完成校验。第二个管理器既不形成独立真源,也不保护不同的失败边界;它只会重复规范折叠,并使两个视图可能出现状态偏差。
-
-## 提案
-
-每个 `Session` 只保留一个 `SurfaceManager`。种子事件与追加事件的接纳流程仍在提交事件之前调用 `validateNext()`,公共表层视图则从同一个管理器读取 `nodes` 与 `replaceGeneration`。
-
-`Session.surface` 只公开只读表层契约,候选事件校验仍由 `Session` 负责。保留 `foldSurface()`,用于离线校验与重建时执行分离的完整日志回放。
-
-## 实施计划
-
-1. 在 `packages/core/session/src/surface.ts` 中,导出结构化的 `SessionSurface` 契约,只包含只读的 `nodes` 与 `replaceGeneration`,并让 `SurfaceManager` 实现该契约。从 `packages/core/session/src/index.ts` 重新导出这个类型,使 `Session.surface` 的声明不再暴露 `validateNext()`。
-2. 在 `Session` 中,用一个主动创建的 `surfaceManager` 替换 `surfaceValidator` 与延迟创建的 `_surface`。种子事件与追加事件都通过该管理器校验,`get surface(): SessionSurface` 返回同一个对象,`deriveMessages()` 也读取同一份节点与代数。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只能制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验拒绝或提交前 `internal/dispatch` 否决都不会留下虚假状态。
-3. 保持 `foldSurface()` 与 `surface.ts` 中的状态转换函数不变。编译并验证 `packages/compact/compact/src/tool-pairing.ts`、`packages/compact/compact-basic/src/region.ts` 和 `packages/context/workspace-context/src/state.ts` 中的直接消费方;它们仍然只读取节点与替换代数。
-4. 扩展 `packages/core/session/tests/surface.spec.ts`:先读取公共视图,再提交无效候选事件,证明拒绝后节点与代数仍停留在已接纳前缀;随后追加有效事件,并把每个结果前缀与 `foldSurface()` 比较。在 `session.spec.ts` 中新增 `internal/dispatch` 否决用例与类型层面的 `SessionSurface` 断言,同时保留种子回放、增量增长、替换、代数和派生缓存用例。
-5. 运行消费表层的请求重建、压缩工具配对、压缩范围与工作区上下文回归套件。在实现 PR 中,先更新 `packages/core/session/README.md`、`docs/core-data-structures/session.md`、已实现会话表层 RFC 及其中文对应文件、翻译记录、`scripts/type-equiv.manifest.json` 和生成的 RFC 索引,再把本 RFC 双语文件移入 `implemented/`。
-
-## 备选方案
-
-**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但普通调用方目前取得的就是借用的表层状态,无法通过声明的只读契约修改它。通过类型断言修改返回的节点数组,本就会破坏派生历史;复制管理器并不能构成可靠的运行时信任边界。
-
-**每次读取都根据完整日志重新计算公共表层。** 该方案不再缓存重复状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。
-
-## 验收标准
-
-- 每个活跃 `Session` 只拥有一个增量 `SurfaceManager`。
-- 种子事件与追加候选事件都在发布前完成校验,拒绝事件时不会留下只修改一半的表层状态。
-- `session.surface`、派生消息、压缩和工作区上下文观察到的节点与替换代数,和接纳路径使用的状态完全一致。
-- `foldSurface()` 仍可用于分离回放,并且对任意已接纳前缀都与活跃管理器一致。
-- 会话表层、种子、请求重建、压缩工具配对和工作区上下文测试全部通过。
-
-## 风险
-
-共享一个管理器会提高只读借用状态契约的重要性,因为恶意类型断言可能同时破坏校验状态和投影视图。实现应返回收窄后的视图,避免通过 `Session.surface` 暴露修改方法;刻意绕过类型契约的 JavaScript 调用方不属于受支持的同进程边界。

+ 1 - 1
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -353,7 +353,7 @@ describe('compaction region transaction', () => {
   it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
     const compact = service()
     const session = conversation(3)
-    const before = session.surface.nodes
+    const before = [...session.surface.nodes]
     const result = await compact.compactRegion(
       before[0]!,
       before[3]!,

+ 2 - 1
packages/core/session/README.md

@@ -35,7 +35,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
 - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
 - `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
 - `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
-- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite.
+- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
 - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
 - `session.seq`, `session.id` — current sequence and readonly typed identity.
 - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
@@ -48,6 +48,7 @@ Durable values need one accepted representation, not a check followed by a secon
 
 - `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
 - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
+- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
 - `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
 - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
 

+ 12 - 20
packages/core/session/src/index.ts

@@ -16,13 +16,14 @@ import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
 import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
 import { snapshotJsonValue } from './json.ts'
 import { SurfaceManager } from './surface.ts'
+import type { SessionSurface } from './surface.ts'
 import { foldRequestHeader } from './request-header.ts'
 
 export * from './types.ts'
 export { isJsonValue, snapshotJsonValue } from './json.ts'
 export type { JsonValue } from './json.ts'
 export { interruptedTurnClosers } from './repair.ts'
-export type { SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
+export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
 export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
 export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
 
@@ -251,22 +252,12 @@ export function renderContextContent(
  */
 export class Session {
   private log: SessionEvent[] = []
-  /** Incremental acceptance state, kept separate from the public lazy view. */
-  private readonly surfaceValidator = new SurfaceManager(this.log)
-
-  /**
-   * Derived surface — a cached order of message-producing event sequences.
-   * Lazily rebuilt from `surfaceOp` markers in the log; processes only new
-   * events (delta) on each access — the log is append-only, so prior events
-   * never change.
-   * Undefined until first accessed (including after fork/seed).
-   */
-  private _surface: SurfaceManager | undefined
+  /** Single incremental owner of surface acceptance and projection state. */
+  private readonly surfaceManager = new SurfaceManager(this.log)
 
   /** The ordered surface over this session's event log. */
-  get surface(): SurfaceManager {
-    if (!this._surface) this._surface = new SurfaceManager(this.log)
-    return this._surface
+  get surface(): SessionSurface {
+    return this.surfaceManager
   }
 
   /**
@@ -309,7 +300,7 @@ export class Session {
         // live append and a full-log fold. The candidate is planned before it
         // enters `log`, so a failure cannot partially mutate the surface.
         try {
-          this.surfaceValidator.validateNext(snapshot)
+          this.surfaceManager.validateNext(snapshot)
         } catch (error: unknown) {
           throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
         }
@@ -402,7 +393,7 @@ export class Session {
       data: dataSnapshot,
       ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
     } as unknown as SessionEvent<T>)
-    this.surfaceValidator.validateNext(event as SessionEvent)
+    this.surfaceManager.validateNext(event as SessionEvent)
 
     if (entry !== undefined) entry.appending = true
     try {
@@ -468,7 +459,7 @@ export class Session {
    *
    * CACHED: each surface node is projected exactly once, when first seen — a
    * call costs O(new nodes), and a surface rewrite (a `replace`;
-   * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
+   * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
    * a fresh snapshot per call (later appends never grow an array a caller
    * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
    * Their content reuses the already frozen durable event data, so the cache
@@ -476,8 +467,9 @@ export class Session {
    * @returns a fresh array of the shared, frozen derived history.
    */
   deriveMessages(): Message[] {
-    const nodes = this.surface.nodes
-    const generation = this.surface.replaceGeneration
+    const surface = this.surface
+    const nodes = surface.nodes
+    const generation = surface.replaceGeneration
     if (generation !== this.derivedGeneration) {
       this.derived = []
       this.derivedNodes = 0

+ 9 - 1
packages/core/session/src/surface.ts

@@ -55,6 +55,14 @@ export interface SurfaceFoldResult {
   replacements: SurfaceFoldReplacement[]
 }
 
+/** Readonly live projection of the message-producing session events. */
+export interface SessionSurface {
+  /** Current surface event sequences in model-visible order. */
+  readonly nodes: readonly number[]
+  /** Monotonic count of committed positional replacements. */
+  readonly replaceGeneration: number
+}
+
 /** Mutable state shared by complete and incremental folds. */
 interface SurfaceFoldState {
   nodes: number[]
@@ -244,7 +252,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
 }
 
 /** Incremental ordered surface view and append-boundary validator. */
-export class SurfaceManager {
+export class SurfaceManager implements SessionSurface {
   /** Shared transition state; replacement history is not retained. */
   private _state = createFoldState()
   /** Last processed seq; -1 folds a seeded log on first access. */

+ 49 - 2
packages/core/session/tests/session.spec.ts

@@ -1,10 +1,18 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
-import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session'
+import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
 
 describe('Session', () => {
+  it('exposes one stable readonly surface view', () => {
+    const session = new Session(SessionId('surface-view'))
+    const surface = session.surface
+
+    expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
+    expect(surface).toBe(session.surface)
+  })
+
   it('derives message history from the event log', () => {
     const session = new Session(SessionId('s1'))
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -1031,6 +1039,45 @@ describe('SessionStore', () => {
     expect(observed).toEqual([appended])
   })
 
+  it('does not publish a surface transition rejected by internal dispatch', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
+    session.append('user/message', {
+      content: [{ type: 'text', text: 'source' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    const surface = session.surface
+    let reject = true
+    ctx.on('internal/dispatch', (_mode, name) => {
+      if (name === 'session/event' && reject) {
+        reject = false
+        throw new Error('reject surface candidate')
+      }
+    })
+
+    expect(() => session.append('assistant/message', {
+      provenance: { provider: 'mock', model: 'mock' },
+      turn: 1,
+      step: 1,
+      content: [{ type: 'text', text: 'replacement' }],
+    }, {
+      surfaceOp: { op: 'replace', start: 0, end: 0 },
+      sourceEventSeqs: [0],
+    })).toThrow('reject surface candidate')
+
+    expect(session.events).toHaveLength(1)
+    expect(surface.nodes).toEqual([0])
+    expect(surface.replaceGeneration).toBe(0)
+
+    session.append('user/message', {
+      content: [{ type: 'text', text: 'next' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    expect(surface.nodes).toEqual([0, 1])
+    expect(surface.replaceGeneration).toBe(0)
+  })
+
   it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)

+ 14 - 1
packages/core/session/tests/surface.spec.ts

@@ -142,6 +142,11 @@ describe('SurfaceManager', () => {
   it('leaves incremental state unchanged when candidate validation fails', () => {
     const s = new Session(SessionId('atomic-validation'))
     s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
+    const surface = s.surface
+    const nodes = surface.nodes
+
+    expect(nodes).toEqual(foldSurface(s.events).nodes)
+    expect(surface.replaceGeneration).toBe(0)
 
     expect(() => s.append(
       'assistant/message',
@@ -150,8 +155,16 @@ describe('SurfaceManager', () => {
     )).toThrow(/missing 0/)
 
     expect(s.events).toHaveLength(1)
+    expect(s.surface).toBe(surface)
+    expect(surface.nodes).toEqual([0])
+    expect(surface.replaceGeneration).toBe(0)
+    expect(surface.nodes).toEqual(foldSurface(s.events).nodes)
+
     s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
-    expect(s.surface.nodes).toEqual([0, 1])
+    expect(surface.nodes).toBe(nodes)
+    expect(surface.nodes).toEqual([0, 1])
+    expect(surface.replaceGeneration).toBe(0)
+    expect(surface.nodes).toEqual(foldSurface(s.events).nodes)
   })
 
   it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {

+ 1 - 0
scripts/type-equiv.manifest.json

@@ -47,6 +47,7 @@
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
+    { "doc": "docs/core-data-structures/session.md", "symbol": "SessionSurface", "source": "packages/core/session/src/surface.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" },
 

+ 4 - 4
website/zh-CN/api/harness/events.md

@@ -324,7 +324,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
 
 - `session` — the session just entered and announced.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L46)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L47)
 
 ### session/disposed
 
@@ -338,7 +338,7 @@ Emitted once when an announced session leaves the store, including publication r
 
 - `session` — the session that is no longer live in the store.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L56)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L57)
 
 ### session/event
 
@@ -353,7 +353,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
 - `session` — the session whose log grew.
 - `event` — the appended event, exactly as recorded.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L68)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L69)
 
 ### session/flush
 
@@ -367,7 +367,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
 
 - `session` — the session whose buffered events must reach durable storage.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L78)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L79)
 
 ## subagent/*
 

+ 9 - 9
website/zh-CN/api/harness/sessions.md

@@ -7,7 +7,7 @@
 In-memory session store (`ctx.sessions`).
 Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L585)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L577)
 
 ### ctx.sessions.create(id?, options?)
 
@@ -23,7 +23,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop
 
 **Returns** the live session, already entered and announced.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L614)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L606)
 
 ### ctx.sessions.prepare(id?, options?)
 
@@ -38,7 +38,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c
 
 **Returns** the constructed session, NOT yet in the store.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L643)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L635)
 
 ### ctx.sessions.enter(session)
 
@@ -53,7 +53,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package
 
 **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L687)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L679)
 
 ### ctx.sessions.announce(session)
 
@@ -65,7 +65,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent
 
 - `session` — the entered session to announce to listeners.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L742)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L734)
 
 ### ctx.sessions.flush(session)
 
@@ -79,7 +79,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t
 
 **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L794)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L786)
 
 ### ctx.sessions.get(id)
 
@@ -93,7 +93,7 @@ Look up a live session.
 
 **Returns** the session, or undefined when no live session has that id.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L818)
 
 ### ctx.sessions.list()
 
@@ -105,7 +105,7 @@ All live sessions, in creation order.
 
 **Returns** a fresh array; mutating it does not affect the store.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L834)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826)
 
 ### ctx.sessions.fork(source, boundary?, childSessionId?)
 
@@ -121,4 +121,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound
 
 **Returns** The created live child session.
 
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L851)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L843)