diff --git a/.agent/skills/assemble-context/scripts/persist_context_freeze.py b/.agent/skills/assemble-context/scripts/persist_context_freeze.py index ba8070a..d1c0415 100644 --- a/.agent/skills/assemble-context/scripts/persist_context_freeze.py +++ b/.agent/skills/assemble-context/scripts/persist_context_freeze.py @@ -79,13 +79,16 @@ def persist_freeze(assemble_result, *, reference_work_id=None, reference_version auth_json = json.dumps(authorization, ensure_ascii=False) if isinstance(authorization, dict) else None with connect() as conn: try: - # manifest_sha256 唯一:同一冻结重放幂等。表是 append-only,冲突只能回读,不能 UPDATE。 + # (manifest, context) 对唯一:同一冻结重放幂等;同清单不同上下文的冻结各得其所。 + # 表是 append-only,冲突只能回读,不能 UPDATE。 + # WHY:检索清单可跨运行不变而上下文每次不同(预防合同/时间进 SHA), + # manifest 单键会让新上下文永远落不了库,接受前置检查按 context_sha 必查不到。 row = conn.execute( "INSERT INTO example_context_freeze(work_id, target_chapter, as_of_chapter, manifest_sha256, " "context_sha256, reference_work_id, reference_version, arm_config, sections, token_budget, " "omitted_sources, authorization_snapshot, creator) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s::jsonb,%s::jsonb,%s) " - "ON CONFLICT (manifest_sha256) DO NOTHING " + "ON CONFLICT (manifest_sha256, context_sha256) DO NOTHING " "RETURNING id, manifest_sha256, context_sha256", (work_id, target_chapter, as_of, manifest_sha, context_sha, reference_work_id, reference_version, json.dumps(arm_config, ensure_ascii=False) if arm_config is not None else None, @@ -94,8 +97,8 @@ def persist_freeze(assemble_result, *, reference_work_id=None, reference_version if not row: row = conn.execute( "SELECT id,manifest_sha256,context_sha256 FROM example_context_freeze " - "WHERE manifest_sha256=%s", - (manifest_sha,), + "WHERE manifest_sha256=%s AND context_sha256=%s", + (manifest_sha, context_sha), ).fetchone() if not row: raise RuntimeError("冻结幂等回读失败") diff --git a/db/ddl/114-example冻结幂等键-清单加上下文对.sql b/db/ddl/114-example冻结幂等键-清单加上下文对.sql new file mode 100644 index 0000000..79e456b --- /dev/null +++ b/db/ddl/114-example冻结幂等键-清单加上下文对.sql @@ -0,0 +1,18 @@ +-- 114 冻结幂等键:manifest 单键 → (manifest, context) 对 +-- +-- WHY:检索清单(manifest)可以跨运行保持不变——例如知识检索全空、细纲与基线 +-- 未变时,多次运行算出同一个 manifest_sha256;但冻结上下文每次不同 +-- (预防合同、规则库版本、生成时间等进 contextSha256)。manifest_sha256 +-- 唯一键使新上下文的冻结永远命中 ON CONFLICT DO NOTHING,落不了库; +-- 接受前置检查(acceptance_state)按 context_sha256 查询必查不到, +-- CONTEXT_NOT_FROZEN 阻断人接受通道。 +-- +-- (manifest, context) 对保持"同一冻结重放幂等":同一份冻结重放,两个 +-- SHA 都相同,仍然幂等;不同上下文的冻结各得其所。 +-- 2026-08-22/23 两阶段写手烟测实证:候选通过双门后被该缺陷挡在人闸前。 + +ALTER TABLE example_context_freeze + DROP CONSTRAINT IF EXISTS uk_example_context_freeze_manifest; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_example_context_freeze_manifest_context + ON example_context_freeze (manifest_sha256, context_sha256); diff --git a/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md b/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md index 3df5de4..7c889f3 100644 --- a/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md +++ b/docs/plans/2026-08-22-阶段F-第一部分-两阶段写手-探索与生成分离.md @@ -62,9 +62,18 @@ | 生成阶段模型发起探索工具调用,`--no-tools` 下无法解析导致挂死 | 写手角色合同写“动笔前按需使用授权工具自主取材”,与生成阶段“无工具”冲突;模型非确定性服从 | `writer.md` 改写为两阶段形态:探索阶段(白名单非空,只探索不写作)/ 生成阶段(白名单为空,禁止工具调用,单条成稿) | | 生成会话被旧单阶段运行(带工具的探索+碎片写作)污染,诱导探索行为 | 生成复用旧会话 `writer-work{W}-ch{T}` | 生成会话独立标签 `writer-gen`(探索为 `writer-explore`) | | 挂死不能无限阻塞 | 看门狗已存在(绝对超时 `maxDurationSeconds`,到点杀进程,`FRAMEWORK_TIMEOUT`) | 无需新增;本次人工提前清理仅为节省等待 | -| 令牌经命令行传入在进程表可见 | tmux 命令串内嵌环境变量赋值 | 运行结束后轮换 `MUSE_AI_NEW_API_TOKEN`;后续用环境文件或进程内注入,不进命令行 | +| 候选过双门后接受前置检查报 `CONTEXT_NOT_FROZEN`,人闸被阻 | 冻结幂等键为 `manifest_sha256` 单键;检索清单跨运行不变而上下文每次不同,新冻结永远 `ON CONFLICT DO NOTHING` 回读旧行 | 迁移 `db/ddl/114-example冻结幂等键-清单加上下文对.sql`:唯一键改 `(manifest, context)` 对;`persist_freeze` 同步改冲突键与回读条件;补落候选 164 冻结行后复验 `ACCEPTANCE_INTENT_READY`;新增零污染真实库测试 | +| 令牌经命令行传入在进程表可见 | tmux 命令串内嵌环境变量赋值 | 改用 600 权限环境文件注入;运行结束后轮换 `MUSE_AI_NEW_API_TOKEN` | -## 6. 未决(下一阶段) +## 6. 真实验证结果(2026-08-23) + +`run-prod-work12-ch3-42beb650`(opus-5 high,指令留空): + +- 探索阶段:约 2 分钟,5 份材料;生成阶段:无工具单条成稿,无碎片化;AI 味诊断 13 条。 +- 机械门通过、语义检测通过;候选 164(candidate_version=9)落库 `state=passed, semantic_status=passed`。 +- 冻结缺陷修复后接受前置检查复验:`ACCEPTANCE_INTENT_READY`——候选停在人闸,等人在决策通道接受/拒绝。 + +## 7. 未决(下一阶段) - 两阶段真实烟测通过后:两阶段成为生产默认形态,单阶段派发降级为对照模式;角色合同与领域文档同步改写(写手 = 探索阶段 + 生成阶段)。 - 评委与抽取智能体的框架派发接入、直调链终态裁决仍按总 plan 阶段 F 推进。 diff --git a/tests/skills/assemble-context/test_persist_freeze_db.py b/tests/skills/assemble-context/test_persist_freeze_db.py new file mode 100644 index 0000000..b68b97c --- /dev/null +++ b/tests/skills/assemble-context/test_persist_freeze_db.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""冻结幂等键(manifest, context)对真实库集成测试。 + +example_context_freeze 是 append-only 表(触发器禁止 UPDATE/DELETE), +无法物理清理测试行;因此本测试零新增:回读一条既有冻结行,按其 +manifest+context 原值重放,验证幂等回读同一行、不新增。 + +"同清单不同上下文各得其所"由 2026-08-23 两阶段烟测实证(候选 164 的 +冻结在旧 manifest 单键下永远落不了库,迁移后落库成功并通过接受前置检查), +并由迁移 114 的 (manifest, context) 唯一索引在库级保证。 + +跑法(需可达 muse-example): +.venv/bin/python tests/skills/assemble-context/test_persist_freeze_db.py +""" + +from __future__ import annotations + +import pathlib +import sys +import unittest + +PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT_DIR = PROJECT_ROOT / ".agent" / "skills" / "assemble-context" / "scripts" +for path in (SCRIPT_DIR,): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from persist_context_freeze import persist_freeze # noqa: E402 +from muse_db import connect # noqa: E402 + + +class FreezeReplayIdempotencyDbTest(unittest.TestCase): + + def test_replay_existing_freeze_is_idempotent_no_new_row(self): + with connect(readonly=True) as conn: + row = conn.execute( + "SELECT work_id, target_chapter, as_of_chapter, manifest_sha256, " + "context_sha256 FROM example_context_freeze ORDER BY id DESC LIMIT 1" + ).fetchone() + self.assertIsNotNone(row, "需要至少一条既有冻结行") + work_id, target_chapter, as_of, manifest_sha, context_sha = row + + assemble_result = { + "context": { + "workId": work_id, + "targetChapter": target_chapter, + "asOf": as_of, + "contextSnapshot": { + "manifestId": "sha256:" + manifest_sha, + "contextSha256": "sha256:" + context_sha, + }, + "retrievalManifest": {"sources": []}, + } + } + with connect(readonly=True) as conn: + before = conn.execute( + "SELECT count(*) FROM example_context_freeze WHERE manifest_sha256=%s", + (manifest_sha,), + ).fetchone()[0] + + out = persist_freeze(assemble_result) + self.assertEqual(out["status"], "frozen") + self.assertEqual(out["manifest_sha256"], manifest_sha) + self.assertEqual(out["context_sha256"], context_sha) + + with connect(readonly=True) as conn: + after = conn.execute( + "SELECT count(*) FROM example_context_freeze WHERE manifest_sha256=%s", + (manifest_sha,), + ).fetchone()[0] + self.assertEqual(after, before, "幂等重放不得新增行") + + +if __name__ == "__main__": + unittest.main()