Переглянути джерело

fix(session): guard V3 delivery activation and smoke writer generation

Tianyi Cui 3 днів тому
батько
коміт
e53df8b64a

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: a5d343d3946199c715d34389e27798c57256af21
-2026-08-31-released-session-format-migrations.zh.md: 7213857ad9145cd1faf6c244c442e2656b3f273d
+2026-08-31-released-session-format-migrations.md: 9f749fe9acc3dc4ca530034791c71513a5e5d2d1
+2026-08-31-released-session-format-migrations.zh.md: 5818419c589d67f8169a06d54ee1b07b5443bfd6

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -66,6 +66,8 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
 
 ### Adjacent version ownership
 
+V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored in V2 must not become an active V3 upload watermark merely because the header changes. Other non-current marker generations retain their ignored meaning. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
+
 V2→V3 provides an identity body stage and a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. Identity preserves accepted logical events and the inherited cut; the header version and successor filename change. Its admission retains installed ordinary event additions and unknown ignorable events, while unknown required events refuse. Unchanged sequence numbers, references, payloads, and ordering make that preservation safe for the identity edge; structural extensions must reassess it rather than inherit an unconditional opaque-event promise. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
 
 A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. V2→V3 validates and derives its cut from markers; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -66,6 +66,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
 
 ### 相邻版本所有权
 
+V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记不能仅因 header 变化就成为有效的 V3 上传水位。其他非当前代际标记仍保持被忽略的含义。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
+
 V2→V3 提供恒等正文 Stage,以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。恒等保留可接受的逻辑事件和继承截点;header 版本与后继文件名会变化。其准入保留已安装的普通事件新增项和未知可忽略事件,同时拒绝未知必需事件。序号、引用、payload 与顺序不变,使恒等迁移边可以安全保留这些数据;结构性扩展必须重新评估,而非继承无条件保留不透明事件的承诺。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
 
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。

+ 11 - 0
packages/session/session-format-catalog/tests/catalog.spec.ts

@@ -102,6 +102,17 @@ describe('first-party Session format catalog', () => {
     expect(JSON.stringify({ header, rows })).toBe(before)
   })
 
+  it.each(['current', 'transformed'] as const)('refuses a v3 delivery marker in v2 input (%s)', (validation) => {
+    const header = { type: 'session', version: 2, id: 'future-delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 }
+    const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
+    restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'unaccepted' } })
+    expect(() => {
+      restore.decodeRow({ type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
+        data: { sessionId: header.id, throughSeq: 0, sessionFormatVersion: 3 } })
+      restore.finish()
+    }).toThrow(/format v2 delivery marker claims target format v3/)
+  })
+
   it('validates complete relationships after streaming migration', () => {
     const stream = sessionFormatCatalog.createRestore({
       type: 'session', version: 1, id: 'invalid-stream', createdAt: 1, delegationDepth: 0,

+ 2 - 2
packages/session/session-format-v2-to-v3/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-format-v2-to-v3/README.md
-README.md: e13c20e55cab2dd043318e8d3a6feaaa3583f3a1
-README.zh.md: 10cf1162d9be4b6a3e9feb455d74b6e845bf817d
+README.md: 704c2391e2bbe3d9adef50722f83faf34431f5ee
+README.zh.md: 91254f1addba0056dbf1ea073d71e5211dbf4e4d

+ 1 - 1
packages/session/session-format-v2-to-v3/README.md

@@ -35,7 +35,7 @@ Use the [catalog](../session-format-catalog/README.md) for restoration. Direct i
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-The header version becomes 3; all other header fields remain unchanged. The stage forwards events and compact runs synchronously. Scalar inherited end-seed markers determine the exact cut at EOF. V3 record encoding and validation reuse the frozen released-v2 implementation without modifying it. Unknown required events remain refusals; installed event types and ignorable unknown events retain their admission rules.
+The header version becomes 3; all other header fields remain unchanged. The stage forwards events and compact runs synchronously, but refuses source delivery markers whose `sessionFormatVersion` is 3 because promotion would activate an unconfirmed target-generation watermark. Scalar inherited end-seed markers determine the exact cut at EOF. V3 record encoding and validation reuse the frozen released-v2 implementation without modifying it. Unknown required events remain refusals; installed event types and ignorable unknown events retain their admission rules.
 
 -----
 

+ 1 - 1
packages/session/session-format-v2-to-v3/README.zh.md

@@ -35,7 +35,7 @@ kind: "package-library"
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-头部版本变为 3,其余头部字段保持不变。阶段同步转发事件和紧凑事件段。标量继承 end-seed 标记在 EOF 确定精确切点。V3 记录编码和校验复用冻结的已发布 v2 实现,不修改该实现。未知必需事件仍被拒绝;已安装事件类型和可忽略的未知事件保留其准入规则。
+头部版本变为 3,其余头部字段保持不变。阶段同步转发事件和紧凑事件段,但拒绝 `sessionFormatVersion` 为 3 的源投递标记,因为升级会激活未经确认的目标代际水位。标量继承 end-seed 标记在 EOF 确定精确切点。V3 记录编码和校验复用冻结的已发布 v2 实现,不修改该实现。未知必需事件仍被拒绝;已安装事件类型和可忽略的未知事件保留其准入规则。
 
 -----
 

+ 9 - 5
packages/session/session-format-v2-to-v3/src/migration.ts

@@ -11,7 +11,7 @@ import type {
 import { assertReleasedV2Header } from '@deepseek-ai/dsh-session-format-v1-to-v2'
 import { assertReleasedV3Header } from './validation.ts'
 
-/** Adjacent identity migration from released v2 to v3. */
+/** Adjacent identity migration from released v2 to v3; refuses source delivery markers claiming v3 acceptance. */
 export const sessionFormatV2ToV3 = defineSessionFormatMigration({
   name: '@deepseek-ai/dsh-session-format-v2-to-v3',
   fromVersion: 2,
@@ -41,10 +41,14 @@ class ReleasedV2ToV3Stage implements SessionFormatMigrationStage {
       this.inheritedEventCount = event.seq
     }
     if (event.type === 'session-log-deepseek/delivery-accepted'
-      && isSessionFormatJsonObject(event.data)
-      && event.data['sessionFormatVersion'] === 2
-      && event.data['sessionId'] !== this.input.sourceHeader.id) {
-      this.lastForeignDeliverySeq = event.seq
+      && isSessionFormatJsonObject(event.data)) {
+      if (event.data['sessionFormatVersion'] === 3) {
+        throw new SessionFormatError('format v2 delivery marker claims target format v3')
+      }
+      if (event.data['sessionFormatVersion'] === 2
+        && event.data['sessionId'] !== this.input.sourceHeader.id) {
+        this.lastForeignDeliverySeq = event.seq
+      }
     }
     context.emitEvent(event)
   }

+ 13 - 0
packages/session/session-format-v2-to-v3/tests/migration.spec.ts

@@ -102,6 +102,19 @@ describe('v2 to v3 identity migration', () => {
     expect(() => stage.finish(context)).toThrow(/wrong Session/)
   })
 
+  it.each([undefined, 0, 1, 2, 4])('preserves delivery marker generation %s verbatim', (version) => {
+    const stage = sessionFormatV2ToV3.createStage({
+      sourceHeader: header, targetHeader: { ...header, version: 3 },
+      sourceInheritedEventCount: 0, sourceKind: 'decoded',
+    })
+    const event = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
+      data: { sessionId: header.id, throughSeq: 0, ...(version === undefined ? {} : { sessionFormatVersion: version }) } }
+    const context = new SessionFormatEventCollector()
+    stage.transformEvent(event, context)
+    expect(stage.finish(context)).toBe(0)
+    expect(context.values).toEqual([event])
+  })
+
   it('checks native v3 delivery ownership without reinterpreting historical markers', () => {
     const artifact = (version: number) => ({
       header: { ...header, version: 3 }, inheritedEventCount: 0, events: [{

+ 28 - 0
python/sdk/tests/test_smoke_model.py

@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import json
 import runpy
 from pathlib import Path
 
@@ -296,6 +297,33 @@ def test_snapshot_generation_comparison_rejects_changed_payload(tmp_path: Path)
         )
 
 
+@pytest.mark.parametrize("version", [2, 4])
+@pytest.mark.parametrize("update", [False, True])
+def test_snapshot_comparison_rejects_noncurrent_writer(
+    tmp_path: Path, version: int, update: bool,
+) -> None:
+    golden = '{"type":"session","version":2}\n'
+    (tmp_path / "session.v2.jsonl").write_text(golden, encoding="utf-8")
+    content = json.dumps({"type": "session", "version": version}) + "\n"
+    with pytest.raises(AssertionError, match="expected current Session format v3"):
+        SMOKE["compare_snapshot_files"](
+            {f"session.v{version}.jsonl": content}, update, tmp_path, ("session.v2.jsonl",),
+        )
+    assert (tmp_path / "session.v2.jsonl").read_text(encoding="utf-8") == golden
+    assert not (tmp_path / "session.v4.jsonl").exists()
+
+
+@pytest.mark.parametrize("version", [2, 3, 4])
+def test_persisted_session_requires_current_writer(version: int) -> None:
+    content = json.dumps({"type": "session", "version": version}) + "\n"
+    path = Path(f"session.v{version}.jsonl")
+    if version == 3:
+        assert SMOKE["assert_persisted_session_version"](path, content) == version
+    else:
+        with pytest.raises(AssertionError, match="expected current Session format v3"):
+            SMOKE["assert_persisted_session_version"](path, content)
+
+
 def test_snapshot_generation_filename_must_match_header(tmp_path: Path) -> None:
     (tmp_path / "session.v1.jsonl").write_text(
         '{"type":"session","version":0}\n', encoding="utf-8",

+ 22 - 1
scripts/smoke-python-runtime.py

@@ -1542,8 +1542,25 @@ def session_header_version(content: str, label: str) -> int:
     return version
 
 
+def assert_current_session_version(version: int, label: str) -> None:
+    """Require generated logs to use the source writer generation, independent of goldens."""
+    source = Path(__file__).resolve().parents[1] / "packages/core/session/src/types.ts"
+    declarations = re.findall(
+        r"^export const SESSION_FORMAT_VERSION = ([0-9]+)$",
+        source.read_text(encoding="utf-8"),
+        re.MULTILINE,
+    )
+    if len(declarations) != 1:
+        raise AssertionError(f"{source}: expected one literal SESSION_FORMAT_VERSION declaration")
+    current_version = int(declarations[0])
+    if version != current_version:
+        raise AssertionError(
+            f"{label}: expected current Session format v{current_version}, got v{version}",
+        )
+
+
 def assert_persisted_session_version(path: Path, content: str) -> int:
-    """Require a raw persistence basename and header to name the same generation."""
+    """Require generated persistence filenames and headers to use the current generation."""
     filename_version = persisted_session_filename_version(path)
     if filename_version is None:
         raise AssertionError(f"non-canonical Session persistence filename: {path.name}")
@@ -1553,6 +1570,7 @@ def assert_persisted_session_version(path: Path, content: str) -> int:
             f"{path.name}: filename declares Session format v{filename_version}, "
             f"header declares v{header_version}",
         )
+    assert_current_session_version(header_version, path.name)
     return header_version
 
 
@@ -2161,6 +2179,9 @@ def compare_snapshot_files(
 
     if tuple(map(role_name, files)) != tuple(map(role_name, filenames)):
         raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
+    for name, content in files.items():
+        if parse_snapshot_session_filename(name) is not None:
+            assert_current_session_version(session_header_version(content, name), name)
     if update:
         directory.mkdir(parents=True, exist_ok=True)
         for name, content in files.items():