From b257ed5e8a8e8b64d2bdadef9a8e426aac35e6fa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 14:46:38 +0800 Subject: [PATCH] docs(session): make the boundary's position and ownership conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 ++-- docs/core-data-structures/session.md | 25 ++++++++++++++------- docs/core-data-structures/session.zh.md | 25 ++++++++++++++------- docs/persistence-catalog.md | 8 +++++-- packages/core/session/src/index.ts | 17 ++++++++------ packages/core/session/src/repair.ts | 6 +++-- packages/core/session/src/types.ts | 4 ++++ 8 files changed, 61 insertions(+), 30 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2df12320f2..8970d7ae1c 100644 --- a/docs/cordis-catalog/services.md +++ b/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` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 9ffcfd8772..95b2754409 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/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 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 03a1a3ee33..fd9dcf6c6c 100644 --- a/docs/core-data-structures/session.md +++ b/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/*`. diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index d1c676f1c9..23202e2b4c 100644 --- a/docs/core-data-structures/session.zh.md +++ b/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/*` 的原因。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7498adce79..6acc650789 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[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 ``` -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 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 318224ff30..e4786e7222 100644 --- a/packages/core/session/src/index.ts +++ b/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 diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index 1a76b2d4a8..a4e82dabfb 100644 --- a/packages/core/session/src/repair.ts +++ b/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 */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 35bd493e1f..aca980ccfe 100644 --- a/packages/core/session/src/types.ts +++ b/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