- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
18 KiB
18 KiB
踩坑与约定(append-only)
实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项,最新在最上。 只记非显而易见的;规格/CLAUDE.md 已写的别重复。
格式:- [date] @skill <坑/约定> — 缘由 + 怎么做
- [2026-06-18] @llm
SqlAlchemyLedgerSink.record改 add-only(T3.8 修,去await flush()):原 add+flush 在并行审里 flush 重入炸(见下条)。改为只session.add(row)——add()同步不让步→并行协程不交错;持久化靠端点/事务commit()(自动 flush)。draft/review/accept/outline 端点末尾均已 commit,记账不丢。凡新增「并行用网关产 usage」的路径,sink 必须保持 add-only,不得在并行段await flush。 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-only,commit 归调用方」(commit 仍会 flush,故记账语义不变)。 - [2026-06-18] @qa/@llm 并行审记账撞 session(M3 真 bug,T3.7 暴露→T3.8 修):三审同 LangGraph superstep 并行,各自
gateway.run()→共用请求 session 的SqlAlchemyLedgerSink.record(session.add+await session.flush())。AsyncSession非并发安全→第二/三审 flush 撞Session is already flushing→被run_review失败隔离吞成incomplete→foreshadow/pace 静默丢失(SSE 无事件、chapter_reviews.foreshadow_sug/pace列空、日志review_node_incomplete error='Session is already flushing')。M2 单审未触发。根因=并行路径里有await flush()(唯一 await 的 DB-IO)。修向:审稿期记账避免并发 flush(add-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session)。 - [2026-06-18] @qa E2E 验「验收后 BackgroundTask 扫描」时序:httpx
ASGITransport下await client.post(...)会等 ASGI app 协程(含 Starlette background tasks)跑完才返回,故 client 上下文退出后断言稳定不 flaky;必 overrideget_session_factory→e2e_sm(真 sessionmaker,同测试 engine/loop),否则默认get_sessionmaker()另建 engine 绑别的 loop。 - [2026-06-18] @frontend 审稿 seed 扩成三态:
useReviewStream.seed入参由ReviewConflict[]改ReviewSeed{conflicts,foreshadow,pace}(进页同时种伏笔建议/节奏留痕,免重审即可看)。ReviewStreamState加foreshadow:ForeshadowSuggestion[](累加) /pace:PaceReport|null(替换非累加,对齐后端 collect「pace 整 dict 入列」)。 - [2026-06-18] @frontend 伏笔 transition 乐观更新只改 status、回滚存快照:
useForeshadow.transition本地先改 status,PATCH 失败setItems(snapshot)回滚 + 读error.details.reason(duplicate/invalid_transition/empty_update) 映射文案。register 不乐观(要服务端 code 唯一校验):成功才追加返回行,重复 code→422 友好提示不改 items。大纲页无 GET 端点:进页initialChapters=[],靠POST .../outline生成填充(无凭据→503 引导去设置)。 - [2026-06-18] @backend 端点级测「无凭据→LLM_UNAVAILABLE」不要靠真实
get_*_gatewaydep 解析——FakeSession不支持.execute,SqlCredentialStore会炸成 500。做法:override 网关 dep 注一个raise AppError(LLM_UNAVAILABLE)的 async 函数(等价无凭据行为),与 review/accept/outline 测试一致。 - [2026-06-18] @backend BackgroundTask 必须自建独立 session:FastAPI BackgroundTasks 在 response 发回、请求 session 关闭后才跑——复用
Depends(get_session)的 session 已关闭会炸。验收后到期扫描经可注入SessionFactory(=get_sessionmaker()),run_overdue_scan内async with factory()开新 session 自己 commit;再加repo_factory缝便于单测注 fake、纯函数 await 不起后台线程。accept 端点新增get_session_factory依赖→所有 accept 测试 client 需 override 它(否则 dep 解析会建真 engine)。 - [2026-06-18] @backend 伏笔登记重复 code / 非法转移 →
VALIDATION(422) 非 409:现有唯一 409 码CONFLICT_UNRESOLVED专指未决冲突禁验收,不复用;details.reason∈duplicate/invalid_transition/empty_update供前端区分。register 捕 SQLAlchemyIntegrityError→rollback→AppError(VALIDATION);transition 捕InvalidTransition/LookupError(404)。 - [2026-06-18] @llm 三审列类型不齐:
chapter_reviews.foreshadow_sug是 JSONB list、pace是 JSONB dict——collect 把ForeshadowReview{planted,resolved}(dict) 扁平成单 list、每条加kind:"planted"|"resolved";PaceReview整体入 dict 列。贴合既有 DB 列类型、不改 @db。新审种加入须同步:collect 列映射 +sse._section_result_events分支 +normalize_review键名。sse.pyimport.collect的 spec.name 常量做 section 分流(无循环:collect 不 import sse)。 - [2026-06-18] @llm 三审并行图测试按
req.output_schema路由 parsed(SchemaRoutingRunGateway)——单FakeRunGateway对所有审返同一 parsed 会让三审拿错 schema。验失败隔离:某 schema 不登记→网关抛 KeyError→run_review隔离为incomplete,无需改 gateway。 - [2026-06-18] @backend 伏笔
record_progressappend JSONB 必须新建 list 重赋值(row.progress = [*old, entry]),不可原地.append()——SQLAlchemy 默认不侦测可变 JSONB 原地突变,原地改不脏标记→flush 丢失。scan_overdue仅在有变更时 flush(空扫描零写)。状态机:transition同态(current==to)幂等放行、CLOSED 为终态(离开 CLOSED 全非法抛InvalidTransition);is_overdue严格大于(current==expected_close_to 仍在窗口内不逾期)、无 expected_close_to 永不逾期。 - [2026-06-18] @llm orchestrator 内每模块各自声明
GatewayRunProtocol(review_node.py与outline_node.py各一份,按模块最小依赖)——不跨模块复用、不在 orchestrator__init__重复导出(__init__只导出 review_node 那个,避免 re-export 名冲突);outline 的为模块内部用。 - [2026-06-18] @qa M2 E2E 多档位假适配器:
config.tier_defaultswriter/analyst/light 默认同 provider(deepseek)→单个假适配器(provider="deepseek")即覆盖三档位;据req.output_schema is ContinuityReview(续审)/否则 digest facts schema 分支返回parsed;三档位用不同input_tokens区分以断言各自落usage_ledger。三端点记账闭环 = review 端点流末 commit + accept 验收事务末 commit 都把网关 ledger flush 真正提交(M1 ledger bug 在 M2 无复发)。 - [2026-06-18] @qa E2E 验证「digest 从终稿非草稿」(#4) 手法:final_text 注入草稿没有的标记串,假 light 适配器把它放进 digest facts 的
summary,断言chapter_digests.facts["summary"]==标记且标记 not in draft_text。accept 409 gate 经 ASGITransport 正常返回(AppError不上抛),断言resp.json()["error"]["details"]["missing_conflict_indices"](ErrorCodeStrEnum →"CONFLICT_UNRESOLVED")。 - [2026-06-18] @frontend 审稿历史
conflicts在 OpenAPI 被标松散{[k]:unknown}[](后端用 dict/JSONB 列)→ 前端lib/review/history.ts安全收窄成ReviewConflict{type,where,refs,suggestion},缺字段给默认、保序(顺序=冲突 gate 的conflict_index身份,不可重排,否则裁决错位)。 - [2026-06-18] @frontend 审稿页重审完成重置裁决草稿用 streaming→done 边沿判定(
wasReviewingRef):不能用 conflicts 长度变化判(同数不同组会漏重置),也不能在 seed(phase=idle,进页种历史留痕)时误触发。 - [2026-06-18] @backend 冲突 gate 判据(accept):冲突身份 = 「最近一条
chapter_reviews.conflicts列表的下标」;裁决ConflictDecision{conflict_index, verdict:accept|ignore|manual, note?};gate 通过 = 裁决的conflict_index集合覆盖range(len(conflicts)),缺判→409CONFLICT_UNRESOLVED+details.missing_conflict_indices/conflict_count;无审稿留痕或零冲突→直通验收。 - [2026-06-18] @backend/@qa ASGITransport 默认
raise_app_exceptions=True:accept 事务回滚测试里,repo 抛的非-AppError(如RuntimeError)会上抛到 client 调用方而非返回 500——测试用pytest.raises(RuntimeError)包住请求调用、再断言session.commits == 0(证明未部分提交)。DB 级原子回滚由 T2.7 真 pg 覆盖。 - [2026-06-18] @llm langgraph 并行节点同写一个 state key 必须配 reducer:并行四审同 superstep 各写
{spec.name: ...}到reviews,无 reducer → LangGraph 抛InvalidUpdateError。用Annotated[dict, merge_reviews](浅合并、返回新 dict、不可变)。ChapterState因逐节点填充改total=False。 - [2026-06-18] @llm langgraph
add_node重载拒收显式Callable类型别名:make_review_node返回的具名BoundReviewNode别名会让 mypy 报 incompatible arg-type。图工厂里改用 inlineasync def闭包(spec 经默认参_spec=spec绑定,避开循环晚绑定),mypy 才推出精确函数类型匹配重载。make_review_node仍作公共缝(供 T2.5 跑单审)单独保留+单测。 - [2026-06-18] @llm 审稿失败隔离两层:审级失败在
run_review内标incomplete(§5.2 任一审不阻塞其余);归一级意外(畸形 entry)→normalize_review发一条error事件后收尾(同normalize_deltas纪律)。 - [2026-06-18] @llm instructor 1.15.3 结构化输出接线:用
AsyncInstructor.create_with_completion(messages=..., response_model=..., model=..., max_tokens=...)同时拿(parsed, raw_completion)——raw.usage用于记账,避免结构化路径丢 usage;usage 提取统一走_usage_from(raw_usage)(文本/结构化/流共用)。adapter 经可注入StructuredClientProtocol 注 fake(测试不联网)。结构化路径ProviderResult.text = parsed.model_dump_json()(日志/留痕),消费走parsed。带output_schema时gateway.run(req).parsed必非 None。 - [2026-06-18] @backend frozen View 的运行时不可变断言因类型分两种:dataclass frozen(
ChapterView)→赋值抛FrozenInstanceError且 mypy 会静态报错(测试里故意赋值需# type: ignore[misc]);Pydantic frozen(DigestView/ReviewView)→抛ValidationError但 mypy 不静态校验(勿加# type: ignore,否则被判 unused-ignore,ruff/mypy 红)。 - [2026-06-18] @backend M2 验收-side repos 只 flush 不 commit:
promote_to_accepted/digest.append/review.record/set_decisions均只flush(),交由 T2.4 验收事务单次commit()(对齐「写库副作用在事务/编排层」);唯独 draftsave_draft仍自 commit(M1 自动保存语义)。(project_id,chapter_no,version)唯一性是 DB 级(T0.2 models 定义),纯 fake 单测不断言它(不引 pg 依赖以免 pytest 门禁需起库)→ 由 T2.7 E2E 真实 DB 覆盖。 - [2026-06-18] @backend/@llm 网关 ledger 只 flush、调用方必须 commit(不变量「写库副作用在编排层不在网关」的代价):
SqlAlchemyLedgerSink.record只flush()不commit();get_session退出时不提交 → 隐式回滚。draft SSE 端点曾因此把usage_ledger行丢掉(T1.9 暴露)。修复:端点在 SSE 流耗尽后await session.commit()(FastAPI 缓存Depends(get_session),网关 ledger 与端点同一 session)。M2 起凡用网关产 usage 的路径(四审/accept)都要确保所在事务最终提交,否则记账静默丢失。 - [2026-06-18] @frontend Next 里消费 SSE 用
fetch+ReadableStreamreader,不用EventSource(EventSource 不能 POST、不能干净 abort)。"停"=AbortController.abort(),吞掉AbortError、已收 token 留在 state 并被自动保存。流前错误(无凭据→503LLM_UNAVAILABLE是 JSON 信封非帧)经!res.ok检出、从{error:{code,message}}解析。 - [2026-06-18] @frontend apps/web 测试环境坑:
server-only包未装→别 import(Server Component 仅靠约定);vitest 是 2.x(无toHaveBeenCalledExactlyOnceWith,用toHaveBeenCalledTimes+toHaveBeenCalledWith);未装 jsdom/testing-library→单测走 node env 测纯逻辑(SSE reducer/帧缓冲、debounce、向导状态机),组件 DOM 渲染留给 T1.9 Playwright。 - [2026-06-18] @backend stub user 未 seed → FK 风险:
projects.owner_id/usage_ledger.owner_id/provider_credentials.owner_id全 FK→users.id,但仓库无 seeded stub user。约定STUB_OWNER_ID = uuid.UUID(int=1)(对齐网关Scope.user_idstub)。任何写这些表的路径(写章记账、立项、存凭据)跑前必须存在该 user 行——T1.4 幂等 seed(startup/lifespan);auth 落地后替换为真实 principal。 - [2026-06-18] @backend 含 nullable 列的唯一约束别用 PG
ON CONFLICT:provider_credentials(owner_id,project_id,provider)/tier_routing(project_id,tier)的project_id可空,PG 默认 NULLS DISTINCT → 全局行(project_id=NULL)的ON CONFLICT不去重、会插重复。T1.7 用显式 read-modify-write(project_id IS NULL)。若 @db 后续给约束加NULLS NOT DISTINCT可改回原生 upsert。 - [2026-06-18] @llm langgraph 1.2.5 实装(pyproject 写
>=0.2.40但装了 1.x,用 1.x API):from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver→AsyncPostgresSaver.from_conn_string(...)(async ctx) →await saver.setup()(只在 migrations/CI)。mypy strict 下:add_node不收functools.partial(用async def闭包绑定依赖);StateGraph[...]/CompiledStateGraph[...]/BaseCheckpointSaver[Any]需写全类型参;测试 state dict 标注: ChapterState、ainvokeconfig 标注: RunnableConfig。 - [2026-06-18] @orchestrator 跨包测试同名碰撞:每包
tests/无 init(避免与顶层tests包撞),但多包并存时 ① pytest 全跑:同名顶层模块fakes.py撞("import file mismatch")→ 测试替身用全局唯一名(fakes_gateway/fakes_providers/fakes_orchestrator);② 聚合mypy packages apps:多个 rootlessconftest.py撞成同名模块 → root pyproject[tool.mypy] exclude=["(^|/)conftest\\.py$"](conftest 仅 fixtures,按包仍受检)。test_*.py 保持全局唯一名。 - [2026-06-18] @backend Fernet 凭据 key 取
settings.credential_enc_key(envCREDENTIAL_ENC_KEY);get_settings是 lru_cache,测试改 env 后须get_settings.cache_clear()(在 client fixture 里)。list/GET 不解密(只回脱敏占位),仅 probe 按需解密——缩小明文暴露面。 - [2026-06-18] @orchestrator 后台 fork 子代理会因 "stream idle timeout" 早夭(T1.1 网关 fork 跑了 8.5 分钟、0 产出、0 文件)。坑:别盲等/盲重启重型 fork。做法:fork 完成后先核对产物文件 + 自跑门禁再认其结果;早夭则编排者内联实现该任务(已有完整上下文),不再二次 fork 同一关键路径任务。
- [2026-06-18] @backend 记忆选择走确定性子串名匹配(在 flatten+sorted 的 beats/facts 文本上 + 显式 entities 列表),非 pg_trgm/向量;§3.4 的 pg_trgm/作者 pin 兜底属后续。
selection与render_cards均按(kind, name)排序→输出与 repo 返回顺序无关;CJK 名按 codepoint 排(乙 U+4E59 < 甲 U+7532),测试断言按 codepoint 而非甲乙丙语义。latest_state只进volatile(卡片),stable_core故意不含它以保缓存前缀字节稳定。 - [2026-06-18] @qa/@llm 包内单测放
packages/<pkg>/tests/(无 init.py,避免与顶层tests包同名冲突);测试替身放独立fakes.py用绝对导入from fakes import ...(不能from .conftest import,相对导入在无包目录下报 no known parent package);conftest.py只放 fixtures。门禁按包跑:uv run {ruff check|mypy|pytest} packages/<pkg>。 - [2026-06-18] @frontend pnpm 11 配置已迁出 package.json/.npmrc → 只读
apps/web/pnpm-workspace.yaml。坑:pnpm run <script>前会跑 verifyDepsBeforeRun 触发隐式 install,遇ERR_PNPM_IGNORED_BUILDS(esbuild/sharp/unrs-resolver 被默认拦截)直接整条命令失败。做法:在pnpm-workspace.yaml写onlyBuiltDependencies:白名单 +verifyDepsBeforeRun: false。 - [2026-06-18] @frontend gen:api 离线管线:
scripts/gen-api.mjs用execFileSync(uv, [...])取后端 OpenAPI(不靠运行中的服务) + 直接调node_modules/.bin/openapi-typescript(别用pnpm exec,会再触发 deps 检查)。改后端 schema 后跑pnpm gen:api重生成lib/api/schema.d.ts。 - [2026-06-18] @backend async engine 跨事件循环坑:
get_sessionmaker用lru_cache,engine 绑定首个 loop;pytest-asyncio 每测试新 loop → 复用会报Connection._cancel never awaited/连接失败。测试里每个 DB 测试get_sessionmaker.cache_clear()在当前 loop 重建并dispose()。 - [2026-06-18] @db mypy strict + 跨包 editable 安装:模型里
from ww_db.base import Base会被判成 Any(报 "cannot subclass Any"),需在根pyproject.toml设[tool.mypy] mypy_path=[...各包源码目录...]+namespace_packages=true,否则 editable 包解析不到源码。 - [2026-06-17] @docs 命名契约:后端 Python/Pydantic 一律 snake_case(字段/schema/JSON),前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型(评审里曾因 TS 旧栈遗留 camelCase 与 Pydantic 契约对不上)。
- [2026-06-17] @docs 数据写入只走验收事务:四审 agent 只读不写;任何 AI 产出入库必经
accept(HITL gate)。别在 agent 节点里直接写库。