refactor(session): rename the seed boundary to end-seed

This commit is contained in:
Hypatia May
2026-07-30 15:32:06 +08:00
parent e8f0934bc2
commit 8527137230
44 changed files with 228 additions and 224 deletions
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/architecture/2026-07-30-session-inherited-log-boundary.md
2026-07-30-session-inherited-log-boundary.md: b5ed9598078fae6fedd4c92613d57a7632eee323
2026-07-30-session-inherited-log-boundary.zh.md: 83d3ce9ae87728228bcdfe73db1b2049bd55cc61
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
2026-07-30-session-end-seed-log-boundary.md: 837531ba0bd3ecf404eb47ee933438546c682a54
2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908
@@ -1,8 +1,8 @@
# Agent Note: the inherited-history log boundary
# Agent Note: the end-seed log boundary
Status: implemented
English | [中文](2026-07-30-session-inherited-log-boundary.zh.md)
English | [中文](2026-07-30-session-end-seed-log-boundary.zh.md)
## Problem
@@ -14,21 +14,21 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth
## Decision
`Session`'s constructor appends the log-only `session/inherited` event as the seeded session's first live write, at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers "which prefix did I inherit" for a consumer holding the object, the event answers it for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
`Session`'s constructor appends the log-only `session/end-seed` event immediately after a non-empty constructor seed, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
A bracket owner reads it positionally: an unmatched opening marker below the boundary belongs to a lifecycle that has ended and cannot still be running. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.
A bracket owner reads it positionally: an unmatched opening marker before `session/end-seed` has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.
The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
Two guards keep the marker from becoming noise. An empty seed writes nothing: a boundary with nothing below it marks nothing. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
Two guards keep the marker from becoming noise. An empty seed writes nothing because there is no seed to end. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
## Persistence needs no changes
The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events below it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log.
The constructor append happens before `enter()`, so the session has no store attachment: the marker never publishes on `session/event`, exactly like the seed events before it. It is instead part of the log `initFor` captures as the creation seed, and persists through the ordinary seed path — `onCreated`'s `createCore` + `appendCore`, or the ownerless-claim suffix write. A consumer that watches the firehose therefore never sees the boundary and must read it from the log.
Consequences for the seam: `load()` stays a pure read, with no revision bump, no `commitRepair` on a balanced log, and no durable mark left by a rejected `append`. **Attaching is not a pure read**, though — a pickup now writes where nothing was written before, so a read-only or full disk fails at `session/created` rather than at the first real turn. That is the one cost this placement adds, and it is narrower than the load-path version's (which failed the load itself).
A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event above it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
A crash before the seed write reaches disk loses the boundary, and that costs nothing: the pending batch is written in order, so a lost boundary means every event after it is lost too. The next pickup reads the same bytes the previous one did, appends its own boundary, and classifies the bracket identically. In-process consumers should prefer `firstLiveSeq`, which is exact before any write.
## Scope of the guarantee
@@ -38,7 +38,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
**A boundary written by the persistence coordinator's cold-load path.** Built first, as the [`session/resumed` boundary](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md), and abandoned before merge. It covers no fork, which is the one case where the inherited bracket's owner may still be running. Because the marker was minted at load it also had to be a durable write on a read path, which spread cost across the seam: a revision bump on every cold load, a `commitRepair` batch on a balanced log with nothing to repair, a stored-time floor to keep the clamp monotonic, and a load that failed against a read-only store.
**A boundary appended at loop start.** The loop is one call above `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
**A boundary appended at loop start.** The loop calls `resumeWith`, so it covers the resume paths, but it misses `fork()` and `adopt()` entirely, and the event would have to fire on `'startup'` — the source a fork child publishes — so `SessionStartSource` would stop discriminating. It also publishes the session before the marker is appended, so a `session/created` listener could observe a seeded log with no boundary.
**Reusing `header.seedLength`.** It is the durable *fork-lineage* boundary and deliberately keeps the original fork value across a resume, where the constructor seed is the whole stored log. The two facts differ and conflating them would lose both.
@@ -50,6 +50,6 @@ Bought: one boundary, written in one place, correct for all six seeded-start pat
Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property.
`session/inherited` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly reports nothing as inherited.
`session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history.
Not built here: no plugin reads the boundary yet. Wiring the compaction seam's staleness check to it is the follow-up that motivated this boundary; the predicate helper belongs with that seam, where a real consumer decides its shape, rather than shipping into core untested against one.
@@ -1,8 +1,8 @@
# Agent Note: 继承历史日志边界
# Agent Note: 种子结束日志边界
Status: implemented
[English](2026-07-30-session-inherited-log-boundary.md) | 中文
[English](2026-07-30-session-end-seed-log-boundary.md) | 中文
## Problem
@@ -14,21 +14,21 @@ Status: implemented
## Decision
`Session` 的构造函数仅日志事件 `session/inherited` 作为带种子会话的第一次实时写入追加,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答"我继承了哪一段前缀",该事件则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
`Session` 的构造函数紧接非空构造种子之后追加仅日志事件 `session/end-seed`作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
括号所有方按位置读取它:位于边界之下的未配对开启标记属于一个已结束的生命周期,不可能仍在运行。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。
括号所有方按位置读取它:`session/end-seed` 之前的未配对开启标记具有更小的 seq,来自构造种子,并且属于一个已结束的生命周期。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。
选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()``adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
两条守卫让这个标记不至于变成噪声。空种子不写入任何内容:下方什么都没有的边界标记不了任何东西。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
两条守卫让这个标记不至于变成噪声。空种子不写入任何内容,因为没有种子需要结束。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
## 持久化无需任何改动
构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它下方的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated``createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。
构造函数中的 append 发生在 `enter()` 之前,因此会话尚无 store attachment:该标记不会在 `session/event` 上发布,与它之前的种子事件完全一样。它属于 `initFor` 捕获的那份创建种子,并通过普通的种子路径落盘——`onCreated``createCore` + `appendCore`,或无主认领的后缀写入。因此监听 firehose 的消费方永远看不到这条边界,必须从日志中读取它。
对 seam 的影响:`load()` 仍是纯读取,没有 revision 递增,对平衡日志不走 `commitRepair`,被拒绝的 `append` 也不留下持久标记。但**接手不是纯读取**——如今一次拾起会在此前完全无写入的路径上产生写入,因此只读存储或磁盘写满会在 `session/created` 处报错,而不是在第一个真实轮次处。这是本放置方式新增的唯一成本,并且比加载路径方案的成本更窄(后者会让加载本身失败)。
若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它上面的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
若崩溃发生在种子写入到达磁盘之前,边界会丢失,而这没有代价:待处理批次按序写入,所以丢掉一个边界意味着它之后的每个事件也一起丢掉。下一次接手读到的字节与上一次相同,会追加自己的边界,并对括号作出完全相同的判定。进程内消费方应优先使用 `firstLiveSeq`,它在任何写入之前就是精确的。
## 保证的适用范围
@@ -38,7 +38,7 @@ Status: implemented
**由持久化协调器的冷加载路径写入边界。** 最先实现的方案,即 [`session/resumed` 边界](../../rejected/architecture/2026-07-29-session-resumed-log-boundary.md),在合并前被放弃。它完全覆盖不到 fork,而 fork 恰恰是继承括号的所有方可能仍然存活的那一种情形。由于标记是在加载时铸造的,它还必须在读取路径上做持久写入,这把成本铺开到整个 seam:每次冷加载都递增 revision、对一份无需修复的平衡日志也要走 `commitRepair`、需要一个已存储时间下限来维持钳制的单调性,以及加载在只读存储上会失败。
**在 loop 启动时追加边界。** loop 位于 `resumeWith` 上一层,因此覆盖恢复路径,但完全漏掉 `fork()``adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
**在 loop 启动时追加边界。** loop 调用 `resumeWith`,因此覆盖恢复路径,但完全漏掉 `fork()``adopt()`,而且事件不得不在 `'startup'` 上触发——那是 fork 子会话发布的来源——于是 `SessionStartSource` 将不再具有区分力。它还会在追加标记之前就发布会话,因此 `session/created` 监听方可能观察到一份没有边界的带种子日志。
**复用 `header.seedLength`。** 它是持久的 *fork 血缘*边界,并且刻意在恢复时保留原始 fork 取值——而恢复时构造种子是整份存储日志。这两个事实并不相同,混同会同时失去两者。
@@ -50,6 +50,6 @@ Status: implemented
代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。
`session/inherited` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地报告没有任何内容被继承
`session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史
此处未做:还没有任何插件读取该边界。把压缩 seam 的陈旧性检查接到它上面,是催生这条边界的后续工作;谓词辅助函数应当归属那个 seam——在那里由真实消费方决定它的形状——而不是未经真实消费方检验就先落进核心。
@@ -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/proposed/architecture/2026-07-29-durable-last-activity-index.md
2026-07-29-durable-last-activity-index.md: 2569ce8dcdef4ce85713e4c3905c3a170be00a98
2026-07-29-durable-last-activity-index.zh.md: 64078982bb09b0f94c41d5424d195d371c1a263f
2026-07-29-durable-last-activity-index.md: 0938e7ea2aa817818ed552fd8b702795840b547a
2026-07-29-durable-last-activity-index.zh.md: 647bce817444c77ac3cd6f51385472fff745212f
@@ -8,11 +8,11 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md)
A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old.
mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/inherited` boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough.
mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough.
The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
The [boundary change](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
The [boundary change](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
## Proposal
@@ -25,7 +25,7 @@ The two shipped backends have opposite constraints, and the proposal is delibera
Three questions must be answered before implementation, and none of them is settled here:
**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/inherited`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/end-seed`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree.
@@ -37,7 +37,7 @@ Three questions must be answered before implementation, and none of them is sett
**Keep mtime and exclude boundary writes from it.** Rejected as impossible rather than undesirable: mtime is the filesystem's, not the backend's. Nothing short of restoring the timestamp after every boundary write would preserve it, and that races any concurrent reader and lies about the artifact.
**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
**Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition.
@@ -61,6 +61,6 @@ Three questions must be answered before implementation, and none of them is sett
## Related
- [The inherited-history log boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
@@ -8,11 +8,11 @@ Status: proposed
一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy``summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。
mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/inherited` 边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy``agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。
mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy``agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了。
已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。
[边界那次变更](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
[边界那次变更](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
## 提案
@@ -25,7 +25,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
实现之前必须回答三个问题,本文对它们都没有定论:
**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/inherited` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。
**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/end-seed` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。
**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。
@@ -37,7 +37,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
**保留 mtime,但把边界的写入排除在它之外。** 否决的理由是做不到,而不是不合意:mtime 属于文件系统,不属于后端。除了在每次边界写入之后把时间戳复原,没有别的办法能保住它,而那样做会与任何并发读取方产生竞态,也会对这份产物撒谎。
**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
**从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合。
@@ -61,6 +61,6 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
## 相关
- [继承历史日志边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
@@ -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/rejected/architecture/2026-07-29-session-resumed-log-boundary.md
2026-07-29-session-resumed-log-boundary.md: 6dbe44cbd8354369e8ed56d8cc838e9771dbaa52
2026-07-29-session-resumed-log-boundary.zh.md: f035773b896131ca87a70b58b19cfafb280601e4
2026-07-29-session-resumed-log-boundary.md: 877b0c780f4c92983d2762243fac4e26d945887a
2026-07-29-session-resumed-log-boundary.zh.md: a6f6ecc0d3f3a349f1a438eec0a84f27f8f649b2
@@ -1,6 +1,6 @@
# Agent Note: Record the resume process boundary in the session log
Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the inherited-history boundary](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)
Status: rejected — the boundary belongs at the seeded-`Session` constructor, which also covers fork and replay; superseded by [the end-seed boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)
English | [中文](2026-07-29-session-resumed-log-boundary.zh.md)
@@ -14,7 +14,7 @@ The pressure to fix this is immediate: moving `compact/start` to its real time p
## Proposal
`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event below the boundary was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one.
`@deepseek-ai/dsh-session-persistence` declares one log-only `session/resumed` with an empty payload and appends exactly one at the end of every cold load, in the same `commitRepair` batch as any crash-repair closers and positioned after them — so every event before the boundary has a smaller seq and was written by a writer that is no longer tracking this log. Ownership lands narrowly on `loadCore()`, the cold-load path reached by `load()` and by `adopt()`. `loadLiveSnapshot()` appends nothing, and the non-mutating `inspect()`/`readFrom()` reads never write one.
The predicate a bracket owner evaluates is purely a function of the log: an unmatched opening marker with a `session/resumed` after it is stale, and one with no `session/resumed` after it is live.
@@ -1,6 +1,6 @@
# Agent Note: 在会话日志中记录恢复的进程边界
Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[继承历史边界](../../implemented/architecture/2026-07-30-session-inherited-log-boundary.md)取代
Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,那里同时覆盖 fork 与回放;由[种子结束边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)取代
[English](2026-07-29-session-resumed-log-boundary.md) | 中文
@@ -14,7 +14,7 @@ Status: rejected — 边界应当落在带种子 `Session` 的构造函数上,
## Proposal
`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之的每个事件都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()``adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()``readFrom()` 读取也从不写入。
`@deepseek-ai/dsh-session-persistence` 声明唯一一个纯日志事件 `session/resumed`,其载荷为空,并在每次冷加载结束时恰好追加一条:与崩溃修复产生的 closers 同处一个 `commitRepair` 批次,且排在它们之后。因此,该边界之的每个事件都有更小的 seq,并且都是由一个不再追踪这份日志的写入方写下的。所有权狭窄地落在 `loadCore()`,也就是 `load()``adopt()` 到达的冷加载路径。`loadLiveSnapshot()` 不追加任何内容,非变更性的 `inspect()``readFrom()` 读取也从不写入。
括号所有方求值的谓词纯粹是日志的函数:未匹配的起始标记之后有 `session/resumed` 的就是陈旧的,之后没有的就是存活的。
+2 -2
View File
@@ -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: fd9dcf6c6c6127a026286f11b6c5dcf16f505abb
session.zh.md: 23202e2b4c4109c4ea4abd79c6409aaf3b593679
session.md: 769d5db301e3e81664c732ab1685c859a00cceb2
session.zh.md: 7af459949eda1b939596c20adf1c9e55f6d2b2b4
+34 -33
View File
@@ -92,27 +92,28 @@ interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Log-only durable projection of {@link Session.firstLiveSeq}: everything
* below it was inherited through a constructor seed (resume, fork, or replay)
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: 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 LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq`.
*
* `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.
* would silently classify every live bracket before it as seed 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
* belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
* about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
* `compact/end`) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
'session/inherited': Record<string, never>
'session/end-seed': Record<string, never>
}
```
@@ -342,25 +343,25 @@ declare class Session {
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* 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.
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit), so consumers
* that replay the log as a publication substitute (telemetry adoption)
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed 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 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
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one 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.
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* 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.
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -540,13 +541,13 @@ A turn encloses one model-loop execution, not the whole session log. Idle inject
The optional `dsh-session/invariant` companion enforces the relations owned by core: turn and step numbering, execution-event enclosure, and same-step tool call/result pairing. Merge-extensible event relations belong to the plugin that declares them, so core does not reject an unknown event merely because no turn is open. See [the standalone-event decision](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md).
## The inherited-history boundary: `session/inherited`
## The end-seed boundary: `session/end-seed`
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.
A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question 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.
An empty seed writes nothing, and a seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
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/*`.
It exists because seed 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 before `session/end-seed` came from the constructor seed and 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/*`.
Activity ordering excludes the boundary through `lastActivityTime(events)`: picking a session up is not work, and lazy resume means browsing writes one, so a resume picker or session list ordering by log tail would float every opened session to the top.
+34 -33
View File
@@ -92,27 +92,28 @@ interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Log-only durable projection of {@link Session.firstLiveSeq}: everything
* below it was inherited through a constructor seed (resume, fork, or replay)
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: 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 LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq`.
*
* `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.
* would silently classify every live bracket before it as seed 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
* belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
* about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
* `compact/end`) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
'session/inherited': Record<string, never>
'session/end-seed': Record<string, never>
}
```
@@ -344,25 +345,25 @@ declare class Session {
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* 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.
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit), so consumers
* that replay the log as a publication substitute (telemetry adoption)
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed 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 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
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one 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.
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* 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.
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
@@ -544,13 +545,13 @@ interface TurnEndReasonMap {
可选的 `dsh-session/invariant` 配套插件会强制核心拥有的关系:轮次与步骤编号、执行事件封闭,以及同一步骤内的工具调用/结果配对。可合并扩展事件的关系由声明它的插件拥有,因此核心不会仅因没有开放轮次就拒绝未知事件。见[独立事件决策](../../.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)。
## 继承历史边界:`session/inherited`
## 种子结束边界:`session/end-seed`
带种子的会话(恢复、fork 或放)这个仅日志事件作为自己的第一次实时写入追加。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答"我继承了哪一段前缀",这个事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
带种子的会话(恢复、fork 或放)紧接构造种子之后追加这个仅日志事件作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且来自种子。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答本生命周期的写入从哪里开始,该事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
空种子不写入任何内容;种子本身已以该边界结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位边界应取日志中的**最后一条**,而不是 `firstLiveSeq`:在一次没有产生工作的拾起之后,下一次拾起会让边界落在该 seq 之下
空种子不写入任何内容;种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`
它之所以必要,是因为继承历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。边界之下的开启标记属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
活动排序通过 `lastActivityTime(events)` 排除该边界:接手会话不算工作,而惰性恢复意味着浏览就会写入一个,因此按日志尾部排序的恢复选择器或会话列表会把每个打开过的会话顶到最前。
+18 -17
View File
@@ -78,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
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)
Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts)
## Events
@@ -404,34 +404,35 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
### `session/*`
#### `session/inherited` — log-only
#### `session/end-seed` — log-only
```ts persistence-catalog
/**
* Log-only durable projection of {@link Session.firstLiveSeq}: everything
* below it was inherited through a constructor seed (resume, fork, or replay)
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: 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 LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq`.
*
* `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.
* would silently classify every live bracket before it as seed 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
* belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
* about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
* `compact/end`) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
'session/inherited': Record<string, never>
'session/end-seed': Record<string, never>
```
Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
#### `session/title` — log-only
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1}
{"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"4fbb7fab-f45a-4e3c-95e0-454a638cf4b3"},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"ecede90b-f918-4b3c-81cc-aefcc375d269"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783352134840,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783352134840,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,12 +12,12 @@
{"type":"assistant/chunk","seq":32,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
{"type":"assistant/chunk","seq":33,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}}}
{"type":"assistant/chunk","seq":34,"time":1783352135771,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"4dd423ef-963c-409e-8238-551c4b10273b"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
{"type":"assistant/message","seq":35,"time":1783352135773,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"d6c3a4bf-20e0-459f-9bc9-945f6650b5f1"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
{"type":"step/end","seq":36,"time":1783352135773,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":37,"time":1783352135773,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session/inherited","seq":38,"time":1785381572223,"data":{}}
{"type":"session/end-seed","seq":38,"time":1785396256785,"data":{}}
{"type":"turn/start","seq":39,"time":1785381572224,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8cfd615d-f500-4de4-b751-d19e27d23016"},"surfaceOp":"append"}
{"type":"user/message","seq":40,"time":1785381572224,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6f050d06-7445-4651-9958-345b6410f3d7"},"surfaceOp":"append"}
{"type":"step/start","seq":41,"time":1785381572240,"data":{"turn":2,"step":1}}
{"type":"request/header","seq":42,"time":1785381572241,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
{"type":"assistant/chunk","seq":43,"time":1783352137783,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -28,6 +28,6 @@
{"type":"assistant/chunk","seq":84,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}}
{"type":"assistant/chunk","seq":85,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}}
{"type":"assistant/chunk","seq":86,"time":1785381572250,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3dc1dc0d-40e0-4cd1-a111-1bcf40a1001e"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"}
{"type":"assistant/message","seq":87,"time":1785381572250,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db43685f-dd37-4558-926d-7a758305a84d"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86],"surfaceOp":"append"}
{"type":"step/end","seq":88,"time":1785381572250,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":89,"time":1785381572251,"data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1}
{"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5c2a4880-9a66-4e38-b8d2-a2f891ff283f"},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"9f3b1367-3a0e-4793-9ecf-ae67a79f24d2"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783352142836,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,12 +12,12 @@
{"type":"assistant/chunk","seq":26,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
{"type":"assistant/chunk","seq":27,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":28,"time":1783352143768,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"132af698-2789-4aaa-aca0-cffd1c58720a"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"assistant/message","seq":29,"time":1783352143771,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"59fa3190-4060-40db-a0a5-97f2fa4172f3"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"step/end","seq":30,"time":1783352143771,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":31,"time":1783352143771,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session/inherited","seq":32,"time":1785381573525,"data":{}}
{"type":"session/end-seed","seq":32,"time":1785396258235,"data":{}}
{"type":"turn/start","seq":33,"time":1785381573526,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f85229b8-dcf8-4d8c-9d61-8ef879ed5974"},"surfaceOp":"append"}
{"type":"user/message","seq":34,"time":1785381573526,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"9f252dc0-3b24-4607-b761-30711b726edb"},"surfaceOp":"append"}
{"type":"step/start","seq":35,"time":1785381573543,"data":{"turn":2,"step":1}}
{"type":"request/header","seq":36,"time":1785381573543,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}}
{"type":"assistant/chunk","seq":37,"time":1783352147925,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -28,6 +28,6 @@
{"type":"assistant/chunk","seq":74,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}}
{"type":"assistant/chunk","seq":75,"time":1785142306309,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}}
{"type":"assistant/chunk","seq":76,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5d0e4a32-f5ee-4992-8223-51cdb14d1db3"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"}
{"type":"assistant/message","seq":77,"time":1785381573552,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a39affbc-097b-4106-912a-99538d18eff8"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"}
{"type":"step/end","seq":78,"time":1785381573553,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":79,"time":1785381573553,"data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -7,7 +7,7 @@
{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]}
{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}}
{"type":"session/inherited","seq":8,"time":0,"data":{}}
{"type":"session/end-seed","seq":8,"time":0,"data":{}}
{"type":"turn/start","seq":9,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":11,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}}
@@ -3,7 +3,7 @@
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}}
{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session/inherited","seq":4,"time":0,"data":{}}
{"type":"session/end-seed","seq":4,"time":0,"data":{}}
{"type":"turn/start","seq":5,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"session/title","seq":7,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}}
@@ -2177,7 +2177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/inherited\': Record<string, never>;\n}',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'steering/message\': {\n turn: number;\n message: UserMessage;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'session/end-seed\': Record<string, never>;\n}',
},
{
name: 'SessionEventMetadataFilter',
+1 -1
View File
@@ -1224,6 +1224,6 @@ describe('agent loop', () => {
// event-by-event identity of types over the inherited prefix
expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual(
agent.session.events.map(e => e.type))
expect(replayed.events.at(-1)?.type).toBe('session/inherited')
expect(replayed.events.at(-1)?.type).toBe('session/end-seed')
})
})
@@ -283,7 +283,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
agentOptions: { provider: 'mock', model: 'mock' },
setup: async (agentCtx) => {
expect(agentCtx.agent?.id).toBe(sessionId)
// The two persisted events plus the inherited-history boundary.
// The two persisted events plus the end-seed marker.
expect(agentCtx.agent?.session.events).toHaveLength(3)
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
@@ -586,10 +586,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
// …below one boundary marking all of it inherited.
// …followed by one end-seed event marking the constructor seed.
expect(a2.session.events.length).toBe(events1.length + 1)
expect(a2.session.firstLiveSeq).toBe(events1.length)
expect(a2.session.events.at(-1)?.type).toBe('session/inherited')
expect(a2.session.events.at(-1)?.type).toBe('session/end-seed')
const replay = new Session(SessionId('replay'), events1)
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
+17 -17
View File
@@ -382,25 +382,25 @@ export class Session {
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* 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.
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit), so consumers
* that replay the log as a publication substitute (telemetry adoption)
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed 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 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
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one 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.
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* 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.
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
*/
readonly firstLiveSeq: number
@@ -442,8 +442,8 @@ export class Session {
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/inherited') {
this.append('session/inherited', {})
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ function validateEvent(
}
case 'user/message':
break
case 'session/inherited':
case 'session/end-seed':
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
break
case 'steering/message':
+3 -3
View File
@@ -2,7 +2,7 @@
* 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 activity-time
* read that must skip the inherited-history boundary — which this module does
* read that must skip the end-seed 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
@@ -14,7 +14,7 @@ import type { SessionEvent } from './types.ts'
/**
* The `time` of the log's last event representing actual work, skipping the
* `session/inherited` boundary — picking a session up is not activity, so
* `session/end-seed` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
*
* Excluded by type, so a pickup time still leaks when a boundary is the last
@@ -25,7 +25,7 @@ import type { SessionEvent } from './types.ts'
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/inherited')?.time
return events.findLast(event => event.type !== 'session/end-seed')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */
+15 -14
View File
@@ -251,27 +251,28 @@ export interface SessionEventMap {
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Log-only durable projection of {@link Session.firstLiveSeq}: everything
* below it was inherited through a constructor seed (resume, fork, or replay)
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: 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 LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq`.
*
* `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.
* would silently classify every live bracket before it as seed 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
* belongs to an ended lifecycle, whatever ended it. NOT a liveness signal
* about other writers — a concurrently live session holds its own boundary
* elsewhere, so tolerating concurrent writers needs a signal beyond the log.
* `compact/end`) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
'session/inherited': Record<string, never>
'session/end-seed': Record<string, never>
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
+6 -6
View File
@@ -52,11 +52,11 @@ function lastSeq(session: Session): number {
return event.seq
}
/** A seeded child's inherited prefix: its log minus the constructor's boundary. */
/** A seeded child's constructor seed: its log minus the end-seed marker. */
function inherited(session: Session): readonly SessionEvent[] {
const events = session.events
const last = events.at(-1)
if (last?.type !== 'session/inherited') throw new Error('seeded child is missing its inherited boundary')
if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
return events.slice(0, -1)
}
@@ -166,12 +166,12 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
// Parent: nothing above the bracket, so its owner must treat it as live.
// Parent: no end-seed event follows the bracket, so its owner treats it as live.
expect(parent.events.at(-1)).toBe(open)
expect(parent.events.some(event => event.type === 'session/inherited')).toBe(false)
// Child: the same bracket sits below its boundary, so it is dead history.
expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
// Child: the same bracket is before end-seed, so it belongs to the seed.
const boundary = child.events.at(-1)
expect(boundary).toMatchObject({ type: 'session/inherited' })
expect(boundary).toMatchObject({ type: 'session/end-seed' })
expect(boundary!.seq).toBeGreaterThan(open.seq)
expect(child.firstLiveSeq).toBe(open.seq + 1)
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
@@ -382,7 +382,7 @@ describe('session-log invariants', () => {
.toThrow(/turn 1 is still open/)
})
it('accepts the inherited boundary whether or not a turn is open', async () => {
it('accepts end-seed whether or not a turn is open', async () => {
const { ctx } = await setup()
// Balanced seed: between turns.
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
@@ -393,7 +393,7 @@ describe('session-log invariants', () => {
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
] })
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited'])
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
// Still open afterwards: the boundary moves no cursor.
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/turn 1 is still open/)
@@ -118,7 +118,7 @@ describe('Session properties', () => {
}))
})
it('replaying an already-inherited log adds no further boundary', () => {
it('replaying a log that already ends in end-seed adds no further marker', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const once = new Session(SessionId(`idem-a-${counter++}`), [...original.events])
+6 -6
View File
@@ -275,8 +275,8 @@ describe('interruptedTurnClosers', () => {
})
describe('lastActivityTime', () => {
const inheritedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/inherited', seq, time, data: {} })
const endSeedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/end-seed', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
@@ -294,16 +294,16 @@ describe('lastActivityTime', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
inheritedAt(2, 9_000),
endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work done above a boundary', () => {
it('reports work appended after end-seed', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
inheritedAt(1, 9_000),
endSeedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
@@ -311,6 +311,6 @@ describe('lastActivityTime', () => {
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
expect(lastActivityTime([inheritedAt(0, 1), inheritedAt(1, 2)])).toBeUndefined()
expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
})
})
+1 -1
View File
@@ -188,7 +188,7 @@ describe('Session', () => {
const replayed = new Session(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
// The seed verbatim, plus the boundary the constructor appends over it.
// The seed verbatim, plus the end-seed event the constructor appends.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq + 1)
expect(replayed.firstLiveSeq).toBe(original.seq)
+2 -2
View File
@@ -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 packages/host/apiproxy/README.md
README.md: c745dd6fb142b5b5a50b4593e3ce0223e2be6728
README.zh.md: 7ddd7a37f7dd45f683a93601637c75b3f1bd8fd2
README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
+1 -1
View File
@@ -44,4 +44,4 @@ None; this package neither assembles nor sends a provider request.
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/inherited` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
+1 -1
View File
@@ -44,4 +44,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/inherited` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会排在它最后一次真实活动之后。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
+1 -1
View File
@@ -165,7 +165,7 @@ function sessionBlank(session: Session): boolean {
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
// Excludes the inherited-history boundary: a resumed-but-untouched session
// Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
+1 -1
View File
@@ -134,7 +134,7 @@ export type QueueAction =
export interface SessionSummary {
sessionId: SessionId
/**
* Last activity. Attached: the last non-`session/inherited` event, since a
* Last activity. Attached: the last non-`session/end-seed` event, since a
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend
* with no per-session file (README Known Limitations covers the skew).
*/
@@ -78,7 +78,7 @@ describe('sessions.list cold merge', () => {
})
})
describe('attached updatedAt excludes the inherited-history boundary', () => {
describe('attached updatedAt excludes end-seed', () => {
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -97,7 +97,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
})
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
const boundary = resumed.events.at(-1)
expect(boundary?.type).toBe('session/inherited')
expect(boundary?.type).toBe('session/end-seed')
expect(boundary?.time).toBeGreaterThan(worked)
const listed = await api.sessions.list(request({}))
@@ -105,7 +105,7 @@ describe('attached updatedAt excludes the inherited-history boundary', () => {
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
// Real work above the boundary does move it.
// Real work appended after end-seed does move it.
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')
@@ -678,8 +678,8 @@ describe('malformed replay and listener lifecycle', () => {
content: [{ type: 'text', text: 'one' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
// Seed, constructor boundary, then the append above. Only the last
// published: the boundary predates store attachment, like the seed.
// Seed, end-seed, then one live append. Only the last event published:
// end-seed predates store attachment, like the seed.
expect(revisions).toEqual([3])
expect(activeMeter.measure(session).logRevision).toBe(3)
@@ -378,9 +378,9 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
await ctx.sessions.flush(child)
const loaded = await ctx.sessionPersistence.load(child.id)
// The inherited prefix reaches disk verbatim, then the child's boundary.
// The constructor seed reaches disk verbatim, then the child's end-seed.
expect(loaded.events.slice(0, source.events.length)).toEqual(source.events)
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: source.events.length })
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length })
expect(loaded.meta).toMatchObject({
id: SessionId('persist-child'),
cwd: '/workspace',
@@ -218,9 +218,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(live)
const loaded = await ctx.sessionPersistence.load(id)
// The seeded constructor's boundary persisted between the stored
// The constructor's end-seed event persisted between the stored
// turn/start and the turn/end appended live.
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/inherited', 'turn/end'])
expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -482,7 +482,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Fork is where the marker earns its keep: the inherited prefix may
// carry a bracket the still-running parent owns.
expect(loaded.events.slice(0, seed.length)).toEqual(seed)
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited', seq: seed.length })
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: seed.length })
// A flush with no NEW events must not double-write.
await ctx.sessions.flush(forked)
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
@@ -515,9 +515,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await second.ctx.sessions.flush(s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
// 0-5 the resumed seed, 6 the boundary, 7-8 the new turn.
// 0-5 the resumed seed, 6 end-seed, 7-8 the new turn.
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
expect(reloaded.events[6]).toMatchObject({ type: 'session/inherited' })
expect(reloaded.events[6]).toMatchObject({ type: 'session/end-seed' })
} finally {
await second.fiber.dispose()
await fix.cleanup()
@@ -798,9 +798,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
// Seeded 0-5 plus the constructor's boundary at 6.
// Seeded 0-5 plus the constructor's end-seed event at 6.
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6])
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -853,9 +853,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}, { inject: ['sessions'] }))
await ctx.sessions.flush(cont)
const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
// 6-7 the claimed suffix; 8 the boundary over the whole seed.
// 6-7 the claimed suffix; 8 end-seed after the whole seed.
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/inherited' })
expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed' })
expect(loaded.meta).toEqual(durableMeta)
expect(loaded.meta.createdAt).toBe(1000)
@@ -1077,7 +1077,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
db.exec('PRAGMA query_only = OFF')
// seq 2: one-event seed, its boundary, then the message appended above.
// seq 2: one-event seed, end-seed, then the live message.
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.resolves.toMatchObject({ items: [{ seq: 2 }] })
})
@@ -185,8 +185,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
'turn/start',
'user/message',
'turn/end',
// The seeded constructor's inherited-history boundary.
'session/inherited',
// The seeded constructor's end-seed marker.
'session/end-seed',
'session/title',
])
expect(ctx.sessionTitle.get(session)?.messageSeqs).toEqual([source.seq])
@@ -126,7 +126,7 @@ describe('in-process policy inheritance', () => {
expect(child.session.header.seedLength).toBe(1)
expect(child.session.firstLiveSeq).toBe(seed.length)
// seq 1 is the inherited-history boundary.
// seq 1 is the constructor's end-seed marker.
expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ seq: 0, data: { mode: 'workspace-write' } },
{ seq: 2, data: { mode: 'read-only', source: 'delegation' } },
@@ -186,12 +186,12 @@ describe('TelemetryCoordinator adoption', () => {
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
// 2 the boundary, 3 the turn/end: both this lifecycle's own writes, while
// 2 end-seed, 3 turn/end: both this lifecycle's own writes, while
// inherited 0-1 stay with the parent stream.
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([['seeded', 2], ['seeded', 3]])
})
it('resume shape: a full-log seed exports only its own boundary and rebuilds the chunk projection', async () => {
it('resume shape: a full-log seed exports only its own end-seed and rebuilds the chunk projection', async () => {
const backend = new FakeBackend()
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -208,7 +208,7 @@ describe('TelemetryCoordinator adoption', () => {
.filter(r => r.attributes['session.id'] === 'resumed')
.map(r => r.attributes['event.seq'])
// Nothing inherited is re-exported; seq 2 is this session's own first
// write — the boundary its constructor appended over the seed.
// write — the end-seed event its constructor appended after the seed.
expect(ofResumed()).toEqual([2])
// The seed fed the projection: the (turn 1, step 1) first chunk already
// shipped from the original process, so its continuation is re-dropped…
+2 -2
View File
@@ -839,7 +839,7 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
// A prior pickup, dated well after the work: the picker must still
// show the work's date, not the pickup's.
{ type: 'session/inherited', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
{ type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
],
})
const harness = await setupSnapshot({
@@ -913,7 +913,7 @@ describe('TUI terminal-state snapshots', () => {
// `/status` appends its own `command/run` first, so the boundary is
// never the tail here. The other two call sites pin it.
dateNow.mockReturnValue(Date.parse('2026-07-22T10:10:11.000Z'))
session.append('session/inherited', {})
session.append('session/end-seed', {})
dateNow.mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z'))
},
}, { columns: 92, rows: 32 })