浏览代码

docs(session): make the boundary's position and ownership conditional

Documentation only; no behavior change.

`firstLiveSeq`'s JSDoc still stated the boundary sits at that seq
unconditionally. Verified reachable on a plain production path: pick up a log,
do no work, pick it up again — the seed already ends in a boundary, so it is
not re-marked and `events[firstLiveSeq]` is undefined. Both that claim and the
firehose-gap sentence are now conditional, with the locate rule ("scan for the
last boundary") matching what types.ts already said.

`repair.ts`'s header claimed this module supplies the boundary. It does not —
the constructor does; this module supplies the activity read that must skip it.
Reworded, and it now names the closer timestamp leak, which is the one real
coupling that justifies `lastActivityTime` living beside the repair synthesis.

Recorded that `Session`'s constructor is the boundary's only legitimate writer,
since the invariant companion constrains nothing and a plugin-side append would
silently turn live brackets below it into dead history.
Hypatia May 1 月之前
父节点
当前提交
b257ed5e8a

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

@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:713`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 

+ 2 - 2
docs/core-data-structures/session.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/core-data-structures/session.md
-session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9
-session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52
+session.md: fd9dcf6c6c6127a026286f11b6c5dcf16f505abb
+session.zh.md: 23202e2b4c4109c4ea4abd79c6409aaf3b593679

+ 17 - 8
docs/core-data-structures/session.md

@@ -101,6 +101,10 @@ interface SessionEventMap {
    * ending in a boundary is not re-marked, so reopening an untouched session
    * does not grow its log per pickup.
    *
+   * `Session`'s constructor is the only legitimate writer. The invariant
+   * companion deliberately constrains nothing here, so a plugin appending one
+   * would silently turn every live bracket below it into dead history.
+   *
    * An owner of a standalone open/close bracket (`compact/start` …
    * `compact/end`) reads it because inherited history and live work are
    * otherwise byte-identical: an unmatched opening marker below the boundary
@@ -346,14 +350,17 @@ declare class Session {
    * session's constructor seed is its full stored log, while its header keeps
    * the original fork value — this field is the in-process construction fact.
    *
-   * Not persisted itself: a nonzero value is projected into the log as the
-   * `session/inherited` event at this seq, which is what a consumer reading
-   * STORED history reads. Prefer this field in-process — it is exact before
-   * the marker's write reaches storage.
+   * Not persisted itself: a seeded session projects it into the log as the
+   * `session/inherited` event, which is what a consumer reading STORED history
+   * reads. Locate that event as the log's LAST boundary, not at this seq — a
+   * seed already ending in one is not re-marked, so reopening an untouched
+   * session leaves the boundary below `firstLiveSeq`. Prefer this field
+   * in-process: it is exact before the marker's write reaches storage.
    *
-   * The marker is appended before the store attaches, so when one exists the
-   * event AT this seq did not publish either: the firehose gap runs through
-   * `firstLiveSeq`, not just below it.
+   * When this lifecycle did append a boundary it sits at this seq, appended
+   * before the store attached, so that event did not publish either — the
+   * firehose gap then runs through `firstLiveSeq` rather than stopping below
+   * it. Otherwise this seq holds an ordinary published write.
    */
   readonly firstLiveSeq: number;
   constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -535,7 +542,9 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
 
 ## The inherited-history boundary: `session/inherited`
 
-A seeded session — resume, fork, or replay — appends this log-only event as its first live write, at the seq its `firstLiveSeq` names. It is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. An empty seed writes nothing, and a seed already ending in one is not re-marked, so reopening an untouched session does not grow its log per open.
+A seeded session — resume, fork, or replay — appends this log-only event as its first live write. It is the durable projection of `firstLiveSeq`: that field answers "which prefix did I inherit" for a consumer holding the object, this event for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
+
+An empty seed writes nothing, and a seed already ending in a boundary is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the boundary as the log's LAST one rather than at `firstLiveSeq`: after a pickup with no work, the next one leaves it below that seq.
 
 It exists because inherited history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker below the boundary belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
 

+ 17 - 8
docs/core-data-structures/session.zh.md

@@ -101,6 +101,10 @@ interface SessionEventMap {
    * ending in a boundary is not re-marked, so reopening an untouched session
    * does not grow its log per pickup.
    *
+   * `Session`'s constructor is the only legitimate writer. The invariant
+   * companion deliberately constrains nothing here, so a plugin appending one
+   * would silently turn every live bracket below it into dead history.
+   *
    * An owner of a standalone open/close bracket (`compact/start` …
    * `compact/end`) reads it because inherited history and live work are
    * otherwise byte-identical: an unmatched opening marker below the boundary
@@ -348,14 +352,17 @@ declare class Session {
    * session's constructor seed is its full stored log, while its header keeps
    * the original fork value — this field is the in-process construction fact.
    *
-   * Not persisted itself: a nonzero value is projected into the log as the
-   * `session/inherited` event at this seq, which is what a consumer reading
-   * STORED history reads. Prefer this field in-process — it is exact before
-   * the marker's write reaches storage.
+   * Not persisted itself: a seeded session projects it into the log as the
+   * `session/inherited` event, which is what a consumer reading STORED history
+   * reads. Locate that event as the log's LAST boundary, not at this seq — a
+   * seed already ending in one is not re-marked, so reopening an untouched
+   * session leaves the boundary below `firstLiveSeq`. Prefer this field
+   * in-process: it is exact before the marker's write reaches storage.
    *
-   * The marker is appended before the store attaches, so when one exists the
-   * event AT this seq did not publish either: the firehose gap runs through
-   * `firstLiveSeq`, not just below it.
+   * When this lifecycle did append a boundary it sits at this seq, appended
+   * before the store attached, so that event did not publish either — the
+   * firehose gap then runs through `firstLiveSeq` rather than stopping below
+   * it. Otherwise this seq holds an ordinary published write.
    */
   readonly firstLiveSeq: number;
   constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -539,7 +546,9 @@ interface TurnEndReasonMap {
 
 ## 继承历史边界:`session/inherited`
 
-带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。它是该字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。空种子不写入任何内容;种子本身已以该事件结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次打开都增长日志。
+带种子的会话(恢复、fork 或重放)把这个仅日志事件作为自己的第一次实时写入追加。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
+
+空种子不写入任何内容;种子本身已以该边界结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位边界应取日志中的**最后一条**,而不是读 `firstLiveSeq`:在一次没有产生工作的拾起之后,下一次拾起会让边界落在该 seq 之下。
 
 它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
 

+ 6 - 2
docs/persistence-catalog.md

@@ -78,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:346`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -417,6 +417,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
  * ending in a boundary is not re-marked, so reopening an untouched session
  * does not grow its log per pickup.
  *
+ * `Session`'s constructor is the only legitimate writer. The invariant
+ * companion deliberately constrains nothing here, so a plugin appending one
+ * would silently turn every live bracket below it into dead history.
+ *
  * An owner of a standalone open/close bracket (`compact/start` …
  * `compact/end`) reads it because inherited history and live work are
  * otherwise byte-identical: an unmatched opening marker below the boundary
@@ -427,7 +431,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
 'session/inherited': Record<string, never>
 ```
 
-Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
 
 #### `session/title` — log-only
 

+ 10 - 7
packages/core/session/src/index.ts

@@ -390,14 +390,17 @@ export class Session {
    * session's constructor seed is its full stored log, while its header keeps
    * the original fork value — this field is the in-process construction fact.
    *
-   * Not persisted itself: a nonzero value is projected into the log as the
-   * `session/inherited` event at this seq, which is what a consumer reading
-   * STORED history reads. Prefer this field in-process — it is exact before
-   * the marker's write reaches storage.
+   * Not persisted itself: a seeded session projects it into the log as the
+   * `session/inherited` event, which is what a consumer reading STORED history
+   * reads. Locate that event as the log's LAST boundary, not at this seq — a
+   * seed already ending in one is not re-marked, so reopening an untouched
+   * session leaves the boundary below `firstLiveSeq`. Prefer this field
+   * in-process: it is exact before the marker's write reaches storage.
    *
-   * The marker is appended before the store attaches, so when one exists the
-   * event AT this seq did not publish either: the firehose gap runs through
-   * `firstLiveSeq`, not just below it.
+   * When this lifecycle did append a boundary it sits at this seq, appended
+   * before the store attached, so that event did not publish either — the
+   * firehose gap then runs through `firstLiveSeq` rather than stopping below
+   * it. Otherwise this seq holds an ordinary published write.
    */
   readonly firstLiveSeq: number
 

+ 4 - 2
packages/core/session/src/repair.ts

@@ -1,8 +1,10 @@
 /**
  * Crash-recovery repair for an interrupted session log. It preserves a fully
  * written final turn and supplies the missing tool, step, and turn boundaries
- * needed to resume with a provider-valid transcript, plus the inherited-history
- * boundary a plugin-owned bracket reads to tell dead history from live work.
+ * needed to resume with a provider-valid transcript, plus the activity-time
+ * read that must skip the inherited-history boundary — which this module does
+ * not write (`Session`'s constructor does) but whose synthetic closers can
+ * inherit that boundary's timestamp, the one real coupling between the two.
  * @module @deepseek-ai/dsh-session/repair
  */
 

+ 4 - 0
packages/core/session/src/types.ts

@@ -260,6 +260,10 @@ export interface SessionEventMap {
    * ending in a boundary is not re-marked, so reopening an untouched session
    * does not grow its log per pickup.
    *
+   * `Session`'s constructor is the only legitimate writer. The invariant
+   * companion deliberately constrains nothing here, so a plugin appending one
+   * would silently turn every live bracket below it into dead history.
+   *
    * An owner of a standalone open/close bracket (`compact/start` …
    * `compact/end`) reads it because inherited history and live work are
    * otherwise byte-identical: an unmatched opening marker below the boundary