Files
writer-work-flow/memory/gotchas.md
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

47 KiB
Raw Blame History

踩坑与约定append-only

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

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


  • [2026-06-20] @frontend 页面重访回显已存内容用 RSC 读 helperlib/api/server.ts)作初值种入 client 组件404/错误降级、绝不阻塞进页(大纲/写作重载):先前大纲页 initialChapters=[]、工作台 useState("") → 重访空白虽然库里有数据。范式:① 大纲fetchOutline(projectId)getJson<OutlineResponse>chapters??[]try/catch 整体降级为 [](项目存在无大纲后端返 200 空列表,但任何错误也不该让大纲页 500useOutline(initial) 已据 initial.length>0status="ready",传非空即回显,生成流照常覆盖。② 草稿fetchDraft 直接复用既有 getJsonOrNull404→null,后端无草稿行正是 404页面 draft?.content ?? ""WorkbenchinitialText。③ 不要把初值塞进 stream 的 reducer——WorkbenchuseState(initialText) 起步即可:流式那条 useEffect 起始 stream.state.text 为空、仅当 phase∈{streaming,done,aborted} 且文本变化才 setText所以初值不会被空流反扑SSE+AbortController+PUT 自动保存全不用动。④ 测纯加载/降级逻辑:lib/api/server.test.tsvi.stubGlobal("fetch", …new Response(JSON, {status})) + afterEach(vi.unstubAllGlobals)node 环境够用,server.ts 只依赖 process.env/fetch),断 200 解包、空/错误→[]、404→null。

  • [2026-06-19] @qa Kimi OAuth E2E 要 token 真落 pgoverride get_session_factorye2e_sm、但不能** monkeypatch kimi_oauth.SqlCredentialStoreK1.3 单测那样会让落库走内存 fake验不到真 pgK1.5):与 K1.3 单测FakeSession/FakeStore/FakeJobRepo验逻辑不同E2E 要验真持久化——让后台 work 自建的 SqlCredentialStore(session) + run_job 默认 SqlJobRepo 保持真,只 override get_session_factorye2e_sm(后台 work 用真 session 写真 pg。仍须monkeypatch 模块级 routers.kimi_oauth._default_http_client→同一 scripted fake后台 work 自建 http 非 dep+ app.dependency_overrides[kimi_oauth._default_http_client]→同一 fake端点 device-auth 走 depcall[0]=device-auth、call[1..]=token 轮询顺序共享)+ monkeypatch routers.kimi_oauth.asyncio.sleep→no-op。断「加密非明文」_ACCESS_TOKEN.encode() not in row.oauth_encFernet 密文里找不到明文字节)+ decrypt_oauth_bundle 回环。Gateway 无公开 adapters accessor → 用 gateway._adapters[provider]KimiCodeAdapter._client.base_url/.default_headers/.api_key(同 K1.2 单测。refresh-on-build 直测 _build_provider_adapter(非经 HTTP更清晰字符串 target monkeypatch "ww_api.services.project_deps.httpx.AsyncClient" + setattr(project_deps,"kimi_refresh",fake)。清理:删 provider_credentials(kimi-code) + jobs(kind=kimi_oauth) + tier_routing(project_id 为 NULL 的全局行)。ruff E501 按显示宽度**算(中文字符占 2 列)——含中文注释/docstring 的行别贴边 100。

  • [2026-06-19] @frontend Kimi Code OAuth 三个端点无 params/body → openapi-fetch 用 api.POST(PATH, {}) / api.GET(PATH);路径用 as const 字面量常量复用K1.4POST .../oauth/start.../oauth/disconnectGET .../oauth/status 的生成 schema 全是 path?: never; ...; requestBody?: never——api.POST(START, {})(传空 init objtypecheck 绿)、api.GET("/settings/providers/kimi-code/oauth/status")(无第二参)。把 start/disconnect 路径抽成模块级 const START = "/settings/...start"api.POST 时,字符串字面量类型仍被 openapi-fetch 推断为合法 path key(无需 as const,但抽常量去重 DRY。② 连接流复用 useJobPollM4零改connectdata.job_idpoll.poll(job_id);轮询终态用 useEffect([poll.status]) + startedRef 守门(同 useStyleLearn 先例,initialPollState.status 默认 "polling" 不能进页即据此显进度)。③ kimi_oauth job 完成态 result 只 {connected, provider}无 token)——jobConnected(job)=job.result?.["connected"]===true期待/解析任何 token 字段。④ user_code a11y<output tabIndex={0} className="select-all">(可读、可全选复制、可聚焦),别用纯 <span>。⑤ 档位路由原是只读展示K1.4 改成可编辑per-tier <select> provider + model input + 保存 PUT {tier_routing});选 OAuth providerkimi-codeapplyProviderChange 自动套 defaultModel="kimi-for-coding"API-key provider 清空 model 待填。

  • [2026-06-19] @backend Kimi OAuth 后台轮询 work 自建 http 走模块级 _default_http_client()(非 dep——测试须 monkeypatch 它 + job_runner.SqlJobRepo,不能只 override depK1.3POST .../starthttp 是 FastAPI dep端点用但后台 workrun_job 独立 session 里调模块级 routers.kimi_oauth._default_http_client() 自建 http请求 dep 已失效——E2E/单测必 monkeypatch.setattr("ww_api.routers.kimi_oauth._default_http_client", lambda: fake_http)(同一 scripted fake 实例供端点 device-auth call[0] + work poll call[1...] 顺序共享)+ monkeypatch.setattr(job_runner,"SqlJobRepo", lambda s: fake_job_repo)run_job 默认 repo_factory 引模块级 SqlJobRepoFakeSession 无 .execute同 T4.3 gotcha+ monkeypatch.setattr(routers.kimi_oauth,"SqlCredentialStore", lambda s: shared_store)work 自建 store 落库)+ override get_session→FakeSession/get_session_factory→FakeSessionFactory + monkeypatch routers.kimi_oauth.asyncio.sleep→no-op不真等 interval 秒。TestClient 同步跑完 background task断言稳定。

  • [2026-06-19] @backend AsyncHttpClient 最小 Protocol 故意不含 aclose(只 postrouter work 关客户端用 getattr(http,"aclose",None)K1.3service 函数start/poll/refresh只需 .post,给 Protocol 加 aclose 会逼所有测试 fake 实现它mypy redhttpx.AsyncClientaclose,故 router workfinallyaclose = getattr(http,"aclose",None); if aclose: await aclose()——既关真客户端、又不污染最小 Protocol、fake 无需实现 aclose。测试模块级属性 monkeypatchproject_deps.httpx/kimi_oauth.asynciomypy strict 报 attr-defined(模块未 re-export——用字符串 target monkeypatch.setattr("pkg.mod.attr.sub", val)mypy 不静态校验字符串)规避,别 setattr(mod.attr, "sub", val)

  • [2026-06-19] @llm Kimi Code 适配器:伪造头走 AsyncOpenAI(default_headers=…)、device_id 走 env-或-uuid5 确定性缺省绝不随机UA 验证为 KimiCLI/1.5 且参考实现未设 X-Msh-K1.2):① coding 端点 OpenAI 兼容 → KimiCodeAdapter 直接子类化 OpenAICompatAdapter(零重复 complete/stream/结构化逻辑),区别只在工厂构造的客户端带 default_headers + coding base_url + access_token 当 api_keySDK 自动发 Authorization: Bearer)。② X-Msh-Device-Id 必须跨调用稳定*kimi_device_id() = env KIMI_DEVICE_ID 优先,否则 uuid5(NAMESPACE_DNS, "ww.kimi-code.device") 模块级常量——别用 uuid4() 每次新建(每次随机 device id 会让 Kimi 侧把每次调用当新设备)。③ 校验对照 picassio/pi-kimi-coder extensions/index.ts:它对 coding API 只显式设 User-Agent: "KimiCLI/1.5"(精确值,本实现采用)、没有 X-Msh-* 头——与 PROGRESS K1 契约「缺 X-Msh→403」不符。本实现按「UA 必需(已验证)+ X-Msh-* 附带(契约要求、真客户端会发、额外标识无害)」处理;到底缺 X-Msh 会不会 403 须 K1.3/K1.5 对真 api.kimi.com 联网验证(含封号风险)。④ 单测断「构造出的 AsyncOpenAI 携带正确 base_url/default_headers/api_key」即可,零联网;工厂分派测试访问 adapter._client 须先 isinstance(adapter, KimiCodeAdapter) 收窄(build_adapter 返回 ProviderAdapter Protocol 无 _client,否则 mypy attr-defined)。

  • [2026-06-19] @db K1.1 把 provider_credentials.api_key_enc 改 nullable 后,跨边界打穿到 @backend credentials.py mypy 报错——这是 K1.3 要吸收的K1.1 不越权修models.pyapi_key_enc: Mapped[bytes | None],但 apps/api/ww_api/services/credentials.pyStoredCredential.api_key_enc: bytesdataclass+ SqlCredentialStore.list_credentials/get_credentialr.api_key_enc(现 bytes|None)→ 2 处 arg-type 错。@db 只拥有 packages/db/,不改 apps/api/(目录所有权)。K1.3 @backend 行动StoredCredential.api_key_encbytes | None(顺带加 auth_type/oauth_enc 字段 + OAuth 读写mypy 即绿。首个新迁移M1M5 全零建表)= K1 新功能预期内。

  • [2026-06-19] @db 新迁移文件 autogenerate 后须手修两处再过 ruff:① autogenerate 模板把 from sqlalchemy.dialects import postgresql 写两遍(重复 importruff F811/格式炸)→删一行;② 模板用单引号 + import 顺序(from alembic import opimport sqlalchemy as sa 前)不合本仓 ruff双引号 + isort→照初始迁移 220ca2e3d53f 的样式手排(import sqlalchemy as safrom alembic import opfrom sqlalchemy.dialects import postgresql双引号docstring 后空行)。改完跑 uv run ruff format + ruff check 验。

  • [2026-06-19] @llm anthropic/google-genai SDK 装上后,注入客户端 Protocol 与 SDK 具体类型在 mypy strict 下不互通——在边界 cast 解决,别硬改 ProtocolR2 follow-up #2instructor.from_anthropic(self._client) 的重载只收 SDK 具体 AsyncAnthropic|... 联合,AnthropicClient Protocol 不匹配 → adapter 内 cast("AsyncAnthropic", self._client) 选中 AsyncInstructor 重载(TYPE_CHECKING 下 import避免运行时硬依赖 SDK。② factory.build_adapter真实 AsyncAnthropic/genai.Client 传给 adapter 时SDK 客户端的 messages/aioread-only 属性,而 Protocol 成员默认 settable → mypy 报「expected settable variable, got read-only attribute」adapter 仅读取这些属性,故在工厂里 cast("AnthropicClient"/"GeminiClient", client) 跨过约束。测试替身缝完好adapter 仍声明 Protocol 形参fake 仍可注入。gemini 无 instructor 那条错(它走 response_schema 非 instructor只有 factory 的 read-only 那条。

  • [2026-06-19] @backend Codex 读端点缺口已补(解紧邻下面这条 @frontend gap+ skill impl 迁回 ww_skillsM5 R1/R3GET /projects/:id/characters + GET /projects/:id/world_entities 已落(复用 C5 读侧 SqlCharacterRepo/SqlWorldEntityRepo,反向 JSONB 解包 {"items":[...]}→list/{"text":...}→str/{"rules":[...]}→list无行返空列表→ @frontend pnpm gen:api 后 CodexPage 初始列表可拉跨会话全量,去掉「会话内回显」局限。② skill registry/沙箱从 ww_core.domain 迁到 packages/skills/ww_skills/——from ww_core.domain import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES 已失效,改 from ww_skills import …。③ 改 packages/skills/pyproject.toml deps去 ww-core、补 ww-llm-gateway+sqlalchemy必须 uv sync 重建 ww-skills否则按旧元数据解析。④ build_gateway_for_tier 现用 ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)(去掉 apps/api 直接 AsyncOpenAI/OpenAICompatAdapter importanthropic/gemini 传 base_url=None 走原生适配器。

  • [2026-06-19] @frontend 【RESOLVED 2026-06-19】Codex 读端点缺口已闭合M5 R1 follow-up #1@backend 补 GET /projects/:id/characters / GET .../world_entitiesC3 扩,反向 JSONB 解包成 API 友好字段)后,前端 gen:api 纳入 CharacterListResponse{characters?:[CharacterCardView]} / WorldEntityListResponse{world_entities?:[WorldEntityCardView]}+lib/api/types 别名);lib/api/serverfetchCharacters/fetchWorldEntities(无行→空列表,非 404仿 fetchForeshadowcodex routeServer ComponentPromise.all 拉项目+人物+世界观全量传初始数据;CodexPage 渲染跨会话持久化真源(刷新不再空),本会话新入库卡经 mergeCharacterCards(纯逻辑,按 name 去重、持久化优先、node 单测)合并补显(不与真源重复)。世界观本期无入库端点,故只渲染真源 + 生成器预览(无 session 合并)。「会话内回显」局限已去除。原 T5.3「生成+本次入库会话内回显」管理入口仍保留generate→ingest 后即时见新行)。

  • [2026-06-19] @frontend 入库 409 冲突详情形 = accept gate 的 details.conflictsReviewConflict 形),不是 missing_conflict_indicesT5.3POST /characters 的 409 CONFLICT_UNRESOLVED 信封 details{conflicts:[{type,where,refs,suggestion}], conflict_count}continuity 预检产出C3 扩 T5.2),区别于 accept 的 details{missing_conflict_indices, conflict_count}(裁决覆盖缺口)。前端 extractIngestConflictserror.code==="CONFLICT_UNRESOLVED" + 收窄 details.conflicts(复用 lib/review/sseReviewConflict 形 + history.ts 同款安全收窄);裁决流 = 展示冲突 → 作者确认 → 带 acknowledge_conflicts=true 重发(非逐条 conflict_index整体放行对齐后端 gate 语义)。错误码同款 503 LLM_UNAVAILABLE 引导去设置(仿 useRefine

  • [2026-06-19] @frontend 命令面板⌘K走全局 keydown + RootLayout 挂载,纯过滤/高亮逻辑抽 lib/command/palette node-env 单测T5.6CommandPaletteMountclientusePathname 注入项目上下文 → projectIdFromPath 正则抽 /projects/<id>;命令清单(导航 + 生成动作)+ filterCommands(title/keywords 大小写无关子串) + moveHighlight/clampHighlight(循环) 全是纯函数、node 单测。组件层只管 ⌘K/Ctrl+K togglewindow.keydown)、焦点(打开 inputRef.focus、Esc/↑↓/Enter、a11yrole=dialog/listbox/option + aria-selected + motion-safe。生成动作生成角色/世界观)= 跳 codex?gen=character|worldCodexPage 读 searchParams 直开对应 tab无需独立 modal 路由)。

  • [2026-06-19] @backend build_gateway_for_tier 多 provider 接线要用真 Fernet key不是 "x"*44T5.2):该函数解密凭据建适配器,凭据是 encrypt_api_key(..., key=CREDENTIAL_ENC_KEY) 加的密key 必须是合法 Fernet32 字节 url-safe base64否则 decrypt_api_keyCredentialKeyError。生成一个:uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())",设 os.environ["CREDENTIAL_ENC_KEY"]=<key> + get_settings.cache_clear()lru_cache。生成/入库端点测试不碰真解密override 网关 dep 注 schema-routing fake故那些仍可用 "x"*44;只有直接调 build_gateway_for_tier 的单测需真 key。

  • [2026-06-19] @backend 生成/入库/precheck 端点测试用 schema-routing fake 网关(按 req.output_schema 返不同 parsedT5.2):一次 ingest 请求触发 precheckoutput_schema=ContinuityReviewworld/character generate 各触发 WorldGenResult/CharacterGenResult——单一固定-parsed 的 FakeReviewGateway 不够。端点测试自带 _SchemaRoutingGateway({Schema: instance})runreq.output_schema 路由,未命中返 None三个网关 depworldbuilder/character_gen/precheckoverride 成同一个。生成预览端点也要 commit()(网关 ledger add-only → 不 commit 则 usage 静默丢,同 draft 坑)——断言 session.commits==1 即便预览不写业务表;冲突 409 路径同样 commit 落 precheck usage。

  • [2026-06-19] @llm 回退/熔断要让适配器把瞬时故障翻译成 TransientProviderError,否则网关只重试/回退它认得的错T5.4Gateway 的重试谓词 _is_retryable 只认 TransientProviderErrorAppError(RATE_LIMITED);普通 Exception/厂商原生异常不重试不回退、直接上抛内容策略拒绝等本就该让作者知情§4.5)。故每个适配器在 complete/streamexcept 里按异常类名(RateLimitError/APITimeoutError/APIConnectionError/InternalServerError/APIStatusError)+status_code(429 或 ≥500) 判定瞬时→包成 TransientProviderError(provider=...)新增适配器必须照做,否则它的 429/5xx 会穿透回退链变成硬失败。packages/llm_gateway/tests/fakes_resilience.pyScriptedAdapter(failures=[...])TransientProviderError 模拟可回退失败、用 AppError(其它码) 模拟不可回退。

  • [2026-06-19] @llm Gateway 构造签名扩了但保留 resolver= 兼容——apps/api 的 build_gateway_for_tier 无需改即不破,但也因此默认不启用回退链T5.4Gateway(adapters, ledger, *, chain_resolver=None, resolver=None, max_retries=2, breaker=None)resolver(单路由) 会被自动包成单元素链,故 project_deps.build_gateway_for_tier 仍传 resolver=resolve_route 照常工作(未回归)。但单元素链=无回退——真正启用回退须 apps/api 改 build_gateway_for_tier:按 DB tier_routing.fallback(StoredRouting.fallback 已有) 预备多个 provider 适配器进 adapters dict + 传 chain_resolver=lambda tier: chain_from_routing(tier, primary, fallback),并可注入跨请求共享的 CircuitBreaker(默认每个 Gateway 实例自带一个、进程内不共享,逐请求新建网关则熔断不跨请求累积)。Anthropic/Gemini 适配器真实接线还需 @devops 加 anthropic/google-genai SDK 依赖(适配器本身懒导入/注入客户端,仅 apps/api 构造真实 client 时需要)。

  • [2026-06-19] @llm character-gen schema 的 traits/speech_ticslist[str],但 DB characters.traits/speech_tics/arc 列是 JSONB dictT5.2 ingest 须转形)T5.1C6 扩 CharacterCardtraits/speech_tics 设计成 list[str](生成产物天然是列表)、arc 设计成 str(弧光一句话),但 packages/db/ww_db/models.py 的列类型是 traits: JSONB dict / arc: JSONB dict / speech_tics: JSONB dict,而 tags/relations 才是 JSONB list。T5.2 入库映射时list 字段tags/relations直落traits/speech_tics 须包成 dict{"items":[...]} 或按语义拆)或由 @db 调列形;arc(str) 同理包 dict。别假设 schema 字段形 == DB 列形——schema 贴生成产物、DB 列贴存储ingest 是转换层。role/backstory 是 Text 列、直落。WorldEntityCard.rules:list[str]world_entities.rules JSONB{"rules":[...]} 或裸 list

  • [2026-06-19] @llm 入库前 continuity 校验是「编排器追加的一道检查」,复用 continuity_spec 而非新建 specT5.1ARCH §6.5precheck_generated_cards 传入 continuity_spec(不是 character-gen 自调、不是新审种),跑生成角色卡 vs 世界观/已有角色真相源、返 list[Conflict]。守不变量#1agent 只经 DB/编排器通信、不互调。T5.2 入库端点拿 conflicts 做 gate有冲突→提示作者裁决/调整,不静默入库(同 accept 的冲突 gate 精神,但这是生成预览阶段、非 chapter accept。独立生成语义网关失败直接上抛端点处理不做 review 图的失败隔离)。

  • [2026-06-19] @qa 学文风 E2E后台 work 自造网关须 monkeypatch style.build_gateway_for_tier(不是 override 网关 depT4.5POST /style 请求阶段的凭据探测走 get_style_extract_gateway(可 dependency_overrides 注假网关绕过 503真正提取run_job 自建的独立 session 上跑 _make_style_learn_work→其内 build_gateway_for_tier(session, store, "analyst") 从凭据建真 OpenAI 适配器(与请求 dep 无关。E2E 无真凭据 → 必 monkeypatch.setattr(ww_api.routers.style, "build_gateway_for_tier", 返真 Gateway 包假适配器)ledger 绑入参 session=后台真 session。写侧 SqlStyleFingerprintWriteRepo + run_job 默认 repo_factory→真 SqlJobRepo 保持不动 → 指纹/job 真落 pg只换 LLM。同 M3override get_session_factorye2e_sm(同测试 engine/loopASGITransport 下 await client.post 等 background task 跑完,轮询 GET /jobs/{id} 稳定见 done。第四审 E2E只 override get_review_gatewayanalyst即覆盖整个四审图四档位同 deepseek 单适配器),假适配器据 req.output_schema is StyleDriftReview 返漂移 parsed回炉假适配器据 req.output_schema is None 返改写串writer 纯文本,保证 refined≠original

  • [2026-06-19] @frontend GET /jobs/{id} 在 OpenAPI 是裸 {[k]:unknown}T0.3 未建命名 schemaT4.4):前端无法直接用生成类型当 JobView须在 lib/jobs/job.ts 安全收窄(narrowJobstatus 非法→queued、progress 夹取 0..100、result 仅取 object 非数组)。同理 chapter_reviews.style 是松散 {[k]:unknown}|nullnormalizeStyleDrift 收窄成 {score(缺省100),segments:[{idx,score(缺省100),label:string|null}]}score 缺省 100 = 后端无指纹降级态)。轮询 reducer/收窄是纯逻辑、node-env 单测hook 只管 setTimeout+fetch。

  • [2026-06-19] @frontend useJobPollinitialPollState.status 默认 "polling",调用方别在挂载即据此显进度T4.4useStyleLearnstartedRef 守门——只在提交过一次 POST /style 后才把 poll.status 映射进 UI否则文风页一进就误显「提取中…」。轮询「停止」靠 reducer 到 done/failed 终态后不再 setTimeout(无 abort 概念,区别于 SSE 的 AbortController。回炉 useRefine 503(error.code==="LLM_UNAVAILABLE")→提示去设置,仿 review 流前 503 检出。

  • [2026-06-19] @backend run_job 后台路径要 monkeypatch job_runner.SqlJobRepo 而非 _default_repo_factoryT4.3run_job(..., repo_factory=_default_repo_factory)repo_factory默认参数值,在函数定义时绑定——monkeypatch.setattr(runner_mod, "_default_repo_factory", ...) 改的是模块属性、改不到已绑定的默认值,无效。_default_repo_factory 体内 return SqlJobRepo(session) 引用的是模块全局 SqlJobRepo,故 monkeypatch runner_mod.SqlJobRepo 才生效(否则后台 set_runningSqlJobRepo(FakeSession).execute 炸、被 run_job 吞成 job_failed。端点测 BackgroundTask 落库路径override get_session_factoryFakeSessionFactory + monkeypatch style.build_gateway_for_tier(产 StyleFingerprintResult)/style.SqlStyleFingerprintWriteRepo(指共享 fake repo) + runner.SqlJobRepo(指 fake job repo)ASGITransport 下 await client.post 会等 background task 跑完,断言稳定(同 run_overdue_scan 时序先例)。

  • [2026-06-19] @backend bulk UPDATE 取受影响行数要 castCursorResultT4.1 reap_zombiesawait session.execute(update(...)) 静态类型是 Result[Any].rowcount 属性mypy strict 报 attr-defined);实际运行时是 CursorResult。做法:from sqlalchemy import CursorResult + cast("CursorResult[Any]", result).rowcountreap_zombies 用一条 bulk UPDATE非逐行running→failed 高效且原子。

  • [2026-06-19] @backend run_job 失败置态要开「全新」sessionT4.1work 抛异常后,原 session 的事务已作废,不能复用它 fail(job_id, ...)——_mark_failedsession_factory() 再开一个独立 session 写 failed + commit。故 run_job 失败路会开 2 个 session业务一个 + 失败置态一个),单测断言 len(factory.sessions)==2run_job 对 repo 只依赖最小 JobLifecycleRepo Protocolset_running/complete/fail——比全 JobRepo 窄,单测 fake 不必实现 create/get/reaprun_overdue_scan 用最小 OverdueScanRepo 先例)。

  • [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 节点里直接写库。

  • [2026-06-19] @qa M5 E2E生成端点的网关get_worldbuilder_gateway/get_character_gen_gateway/get_precheck_gateway)是请求 scope FastAPI 依赖(内部虽调 build_gateway_for_tier,但作为 Depends 暴露)→ E2E 直接 app.dependency_overrides[get_*_gateway]=真Gateway包假适配器 即可,无需 monkeypatch build_gateway_for_tier(区别于 M4 学文风:那是 run_job 后台自建网关,须 monkeypatch 模块级 style.build_gateway_for_tier)。

  • [2026-06-19] @qa served_by(fell_back/degraded) 不出 API(仅观测/记账标注)→ 端到端 HTTP 路径要证「回退真发生」,断言 DB 真源 usage_ledger.provider = fallback 名而非 primary网关记账记实际服务方ARCH §4.5。HTTP fallback 用真 Gateway + chain_resolver=chain_from_routing(...) + primary 假适配器每次 completeTransientProviderError(备 ≥max_retries+1 次失败耗尽重试)→ 切 fallback能力降级路径无支持者经直测 Gateway.runserved_by.degraded 更清晰。

  • [2026-06-19] @qa ww_agents.Conflict.typeConflictType Literal(性格漂移/能力不符/设定违例/地理矛盾/时间线倒错)——构造 precheck 假冲突须用枚举值之一(如「设定违例」),随手写「设定冲突」会 pydantic literal_error。

  • [2026-06-19] @llm ⚠️已被下条推翻(保留作纠错轨迹):曾写 Kimi Code coding API 的伪造头「只发 User-Agent: KimiCLI/1.5、不带 X-Msh-*」——该结论源自分歧/错误参考 picassio/pi-kimi-coderUA-only导致误把头集裁成 UA-only。勿采纳本条,见下条。

  • [2026-06-19] @backend Kimi Code device authorization 必须带 scope=kimi-codestart_device_authorization 请求体 = {"client_id": <id>, "scope": "kimi-code"}。K1.5 联网实测:省略 scope 登录能拿到真 JWT但调 api.kimi.com/coding/v1401 Invalid Authentication——token 缺 coding entitlement。scope=kimi-code 是 coding-agent 文档化 scopeooojustin/opencode-kimi constants.ts 发送kimi-cli v1.41.0 已不发但服务端仍接受。scope 放 device_authorizationtoken 交换(poll_token/刷新(refresh不带 scopedevice flow 惯例)。⚠️ 此前 K1.3 单测/decision/contract 写「省略 scope」误信 kimi-cli v1.41.0),已全部修正。

  • [2026-06-19] @llm Kimi Code coding API 伪造头真源 = ooojustin/opencode-kimisrc/headers.ts+src/constants.ts1:1 镜像 kimi-cli v1.37.0),发完整 7 头、UA KimiCLI/1.37.0K1.2 校正,推翻上方 pi-kimi-coder UA-only 那条):picassio/pi-kimi-coder 是分歧/错误参考(只发 UA照它裁成 UA-only 是误修,已回退。kimi_code_headers()User-Agent=KimiCLI/1.37.0 + X-Msh-Platform=kimi_cli(字面常量,非 OS 名)+ X-Msh-Version=1.37.0==UA 版本)+ X-Msh-Device-Namesocket.gethostname() ASCII 化)+ X-Msh-Device-ModelmacOS=f"macOS {platform.mac_ver()[0]} {platform.machine()}"machine() 原样 arm64/x86_64 不归一化)+ X-Msh-Os-Versionplatform.version()+ X-Msh-Device-Id(稳定 32 位无连字符 uuid4().hexenv KIMI_DEVICE_ID 优先→否则读/建 ~/.kimi/device_id 复用,绝不每次随机,否则 Kimi 侧每次当新设备)。源码若与此有出入以源码为准(本次核对 master 一致)。⚠️ HTTP 头值含非 ASCII 会被底层 fetch/httpx 拒host 派生值Device-Name/Model/Os-Version须 ASCII 化(asciiHeaderValue:裁 \x20-\x7e 之外 + trim空回退 unknown)。单测用 env KIMI_DEVICE_ID 注入固定 id 保 CI 确定(不触真 ~/.kimi。ruff E501 按显示宽度算(中文占 2 列),含中文 docstring/注释的行别贴边 100。

  • [2026-06-19] @backend assembleC5此前只从 world_entities/characters/style/rules + cards/foreshadow/digests/outline.beats 组装,从不含项目 premise/logline/theme/title也无「写章」指令 → 全新项目(只有 premise、无世界观/角色/大纲)产出 stable_core=""+volatile=""writer 的 user message 为空LLM 回 400 "the message at position 0 with role 'user' must not be empty"。修复:MemoryRepos 加第 8 个 repo project:ProjectSpecRepospec(project_id)->ProjectSpecView{title,logline?,premise?,theme?},按 project_id 读 projects,无 owner 过滤——owner 隔离归路由层);assemble 把「作品蓝本」放进 stable_core 首段(书级 spec=定型→缓存前缀,不违 #9并让 volatile 始终含 请创作第 {chapter_no} 章的正文。(易变指令)。⚠️ 任何手搓 MemoryRepos(...) 的地方(含测试 fake现在必须project= 字段,否则 dataclass 缺参报错。无 DDL 变更(只读既有 projects 列),无迁移。