Files
writer-work-flow/memory/gotchas.md
Yaojia Wang 5fb7bfb1de feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(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
2026-06-18 14:21:17 +02:00

18 KiB
Raw Blame History

踩坑与约定append-only

实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项,最新在最上。 只记非显而易见的;规格/CLAUDE.md 已写的别重复。

格式:- [date] @skill <坑/约定> — 缘由 + 怎么做


  • [2026-06-18] @llm SqlAlchemyLedgerSink.record 改 add-onlyT3.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-onlycommit 归调用方」commit 仍会 flush故记账语义不变
  • [2026-06-18] @qa/@llm 并行审记账撞 sessionM3 真 bugT3.7 暴露→T3.8 修):三审同 LangGraph superstep 并行,各自 gateway.run()→共用请求 sessionSqlAlchemyLedgerSink.record(session.add+await session.flush())。AsyncSession 非并发安全→第二/三审 flush 撞 Session is already flushing→被 run_review 失败隔离吞成 incompleteforeshadow/pace 静默丢失SSE 无事件、chapter_reviews.foreshadow_sug/pace 列空、日志 review_node_incomplete error='Session is already flushing'。M2 单审未触发。根因=并行路径里有 await flush()(唯一 await 的 DB-IO。修向审稿期记账避免并发 flushadd-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session
  • [2026-06-18] @qa E2E 验「验收后 BackgroundTask 扫描」时序httpx ASGITransportawait client.post(...) 会等 ASGI app 协程(含 Starlette background tasks跑完才返回故 client 上下文退出后断言稳定不 flaky必 override get_session_factorye2e_sm(真 sessionmaker同测试 engine/loop否则默认 get_sessionmaker() 另建 engine 绑别的 loop。
  • [2026-06-18] @frontend 审稿 seed 扩成三态useReviewStream.seed 入参由 ReviewConflict[]ReviewSeed{conflicts,foreshadow,pace}(进页同时种伏笔建议/节奏留痕,免重审即可看)。ReviewStreamStateforeshadow:ForeshadowSuggestion[](累加) / pace:PaceReport|null(替换非累加,对齐后端 collect「pace 整 dict 入列」)。
  • [2026-06-18] @frontend 伏笔 transition 乐观更新只改 status、回滚存快照useForeshadow.transition 本地先改 statusPATCH 失败 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_*_gateway dep 解析——FakeSession 不支持 .executeSqlCredentialStore 会炸成 500。做法override 网关 dep 注一个 raise AppError(LLM_UNAVAILABLE) 的 async 函数(等价无凭据行为),与 review/accept/outline 测试一致。
  • [2026-06-18] @backend BackgroundTask 必须自建独立 sessionFastAPI BackgroundTasks 在 response 发回、请求 session 关闭后才跑——复用 Depends(get_session) 的 session 已关闭会炸。验收后到期扫描经可注入 SessionFactory(=get_sessionmaker())run_overdue_scanasync 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.reasonduplicate/invalid_transition/empty_update 供前端区分。register 捕 SQLAlchemy IntegrityError→rollback→AppError(VALIDATION)transition 捕 InvalidTransition/LookupError(404)。
  • [2026-06-18] @llm 三审列类型不齐chapter_reviews.foreshadow_sug 是 JSONB listpace 是 JSONB dict——collect 把 ForeshadowReview{planted,resolved}(dict) 扁平成单 list、每条加 kind:"planted"|"resolved"PaceReview 整体入 dict 列。贴合既有 DB 列类型、不改 @db。新审种加入须同步collect 列映射 + sse._section_result_events 分支 + normalize_review 键名。sse.py import .collect 的 spec.name 常量做 section 分流无循环collect 不 import sse
  • [2026-06-18] @llm 三审并行图测试按 req.output_schema 路由 parsedSchemaRoutingRunGateway)——单 FakeRunGateway 对所有审返同一 parsed 会让三审拿错 schema。验失败隔离某 schema 不登记→网关抛 KeyError→run_review 隔离为 incomplete,无需改 gateway。
  • [2026-06-18] @backend 伏笔 record_progress append 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 内每模块各自声明 GatewayRun Protocolreview_node.pyoutline_node.py 各一份,按模块最小依赖)——不跨模块复用、不在 orchestrator __init__ 重复导出__init__ 只导出 review_node 那个,避免 re-export 名冲突outline 的为模块内部用。
  • [2026-06-18] @qa M2 E2E 多档位假适配器config.tier_defaults writer/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"]ErrorCode StrEnum → "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 长度变化判(同数不同组会漏重置),也不能在 seedphase=idle进页种历史留痕时误触发。
  • [2026-06-18] @backend 冲突 gate 判据accept:冲突身份 = 「最近一条 chapter_reviews.conflicts 列表的下标」;裁决 ConflictDecision{conflict_index, verdict:accept|ignore|manual, note?}gate 通过 = 裁决的 conflict_index 集合覆盖 range(len(conflicts))缺判→409 CONFLICT_UNRESOLVED + details.missing_conflict_indices/conflict_count;无审稿留痕或零冲突→直通验收。
  • [2026-06-18] @backend/@qa ASGITransport 默认 raise_app_exceptions=Trueaccept 事务回滚测试里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。图工厂里改用 inline async def 闭包spec 经默认参 _spec=spec 绑定避开循环晚绑定mypy 才推出精确函数类型匹配重载。make_review_node 仍作公共缝(供 T2.5 跑单审)单独保留+单测。
  • [2026-06-18] @llm 审稿失败隔离两层:审级失败在 run_review 内标 incomplete§5.2 任一审不阻塞其余);归一级意外(畸形 entrynormalize_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 用于记账,避免结构化路径丢 usageusage 提取统一走 _usage_from(raw_usage)(文本/结构化/流共用。adapter 经可注入 StructuredClient Protocol 注 fake测试不联网。结构化路径 ProviderResult.text = parsed.model_dump_json()(日志/留痕),消费走 parsedoutput_schemagateway.run(req).parsed 必非 None
  • [2026-06-18] @backend frozen View 的运行时不可变断言因类型分两种dataclass frozen(ChapterView)→赋值抛 FrozenInstanceErrormypy 会静态报错(测试里故意赋值需 # type: ignore[misc]Pydantic frozen(DigestView/ReviewView)→抛 ValidationErrormypy 不静态校验勿加 # type: ignore,否则被判 unused-ignoreruff/mypy 红)。
  • [2026-06-18] @backend M2 验收-side repos 只 flush 不 commitpromote_to_accepted/digest.append/review.record/set_decisions 均只 flush(),交由 T2.4 验收事务单次 commit()(对齐「写库副作用在事务/编排层」);唯独 draft save_draft 仍自 commitM1 自动保存语义)。(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.recordflush()commit()get_session 退出时不提交 → 隐式回滚。draft SSE 端点曾因此把 usage_ledger 行丢掉T1.9 暴露)。修复:端点在 SSE 流耗尽后 await session.commit()FastAPI 缓存 Depends(get_session),网关 ledger 与端点同一 sessionM2 起凡用网关产 usage 的路径(四审/accept都要确保所在事务最终提交,否则记账静默丢失。
  • [2026-06-18] @frontend Next 里消费 SSE 用 fetch+ReadableStream reader不用 EventSourceEventSource 不能 POST、不能干净 abort。"停"=AbortController.abort(),吞掉 AbortError、已收 token 留在 state 并被自动保存。流前错误无凭据→503 LLM_UNAVAILABLEJSON 信封非帧)经 !res.ok 检出、从 {error:{code,message}} 解析。
  • [2026-06-18] @frontend apps/web 测试环境坑:server-only 包未装→别 importServer 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_id stub。任何写这些表的路径写章记账、立项、存凭据跑前必须存在该 user 行——T1.4 幂等 seedstartup/lifespanauth 落地后替换为真实 principal。
  • [2026-06-18] @backend 含 nullable 列的唯一约束别用 PG ON CONFLICTprovider_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 APIfrom langgraph.checkpoint.postgres.aio import AsyncPostgresSaverAsyncPostgresSaver.from_conn_string(...)(async ctx) → await saver.setup()(只在 migrations/CI。mypy strict 下:add_node 不收 functools.partial(用 async def 闭包绑定依赖);StateGraph[...]/CompiledStateGraph[...]/BaseCheckpointSaver[Any] 需写全类型参;测试 state dict 标注 : ChapterStateainvoke config 标注 : RunnableConfig
  • [2026-06-18] @orchestrator 跨包测试同名碰撞:每包 tests/init(避免与顶层 tests 包撞),但多包并存时 ① pytest 全跑:同名顶层模块 fakes.py 撞("import file mismatch")→ 测试替身用全局唯一名(fakes_gateway/fakes_providers/fakes_orchestrator);② 聚合 mypy packages apps:多个 rootless conftest.py 撞成同名模块 → root pyproject [tool.mypy] exclude=["(^|/)conftest\\.py$"]conftest 仅 fixtures按包仍受检。test_*.py 保持全局唯一名。
  • [2026-06-18] @backend Fernet 凭据 key 取 settings.credential_enc_key(env CREDENTIAL_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 兜底属后续。selectionrender_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 packageconftest.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 触发隐式 installERR_PNPM_IGNORED_BUILDS(esbuild/sharp/unrs-resolver 被默认拦截)直接整条命令失败。做法:在 pnpm-workspace.yamlonlyBuiltDependencies: 白名单 + verifyDepsBeforeRun: false
  • [2026-06-18] @frontend gen:api 离线管线:scripts/gen-api.mjsexecFileSync(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_sessionmakerlru_cacheengine 绑定首个 looppytest-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 产出入库必经 acceptHITL gate。别在 agent 节点里直接写库。