《不给小团队上数据库:用 Git 仓库当需求看板的实践》

需求管理模块:用 Git 仓库当数据库的团队看板

引言

给小团队做一个共享需求看板,绝大多数人的第一反应是:起个服务 + 上个数据库。AgentHub 的需求管理模块选了另一条路------不建中心化服务,把数据放进一个内网 Git 仓库 requirements-repo,git 同时充当存储层和同步层 。多个成员在各自桌面端操作同一份需求数据,写操作不走 LLM,走一条确定性的 HTTP 写路径(pull --rebase → 写文件 → commit+push,带 rebase 冲突自愈);AI(requirements-agent)只在对话里做需求澄清、产出草稿,草稿经人工确认后才落到那条确定性路径上。

一句话概括:git 仓库当数据库 + 确定性写路径 + AI 只产草稿、人确认落库

这篇文章讲这套架构的三层结构、数据模型、冲突自愈的实现,以及上线过程中真实踩过的七个坑------每一个都有具体的修复逻辑,比架构图本身更有参考价值。

一、模块定位与三层架构

要解决的问题:团队几个人在各自的桌面端上看同一块需求看板,拖卡片、改状态、评论、聊需求。常规做法是起一个中心化服务 + 数据库,但这个模块选了 git 仓库:每个人本地持有一份 clone,读写都通过 git 同步。为什么这么选、代价是什么,在第八节的设计取舍里展开,这里先讲结构。

模块分三层,职责切得很干净:

bash 复制代码
┌────────────────────────────────────────────────────────────────────┐
│  第一层:前端看板/聊需求 UI(desktop/)                               │
│    RequirementsPage.vue + components/requirements/*                │
│    看板五列拖拽 / 右键菜单 / Review 面板 / 原型浮窗 / 聊需求面板   │
│    纯 HTTP 客户端,不直接碰 git                                    │
└────────────────────────────────────────────────────────────────────┘
              │ POST /api/requirements/read|write        │ POST /chat?stream=1
              ▼ (确定性写路径)                            ▼ (AI 对话路径)
┌──────────────────────────────────┐  ┌──────────────────────────────────┐
│  第二层:后端确定性写路径         │  │  第三层:requirements-agent       │
│  src/requirements/               │  │  走通用 /chat SSE 通道,伪造      │
│    repo.ts     git 操作          │  │  InboundMessage 推进消息总线,    │
│    intents.ts  6 种写意图        │  │  由 agent 的 LLM loop 处理       │
│  模块头注释写明定位:"由 HTTP    │  │  唯一产出:submit_requirement_    │
│  端点调用,不经过 agent LLM     │  │  draft 工具产草稿 -- 不写仓库    │
│  loop(写操作是确定性的,无      │  │                                  │
│  需 LLM 编排)"                  │  │                                  │
└──────────────────────────────────┘  └──────────────────────────────────┘
              │ pull --rebase -> 写文件 -> commit+push         │ 草稿 JSON 随 SSE
              ▼                                                ▼ tool_result 回前端
┌──────────────────────────────────┐                  前端 Review 面板人工确认
│  本地 clone                      │                  确认后调 create_req 意图,
│  workspace/requirements-agent/   │ <──────────────── 回到第二层确定性落库
│    requirements-repo             │
└──────────────────────────────────┘
              │ git push
              ▼
┌──────────────────────────────────┐
│  团队共享远端(内网 Git 仓库)   │
└──────────────────────────────────┘

读这张图的关键是两条路径在"落库"这一点汇合 :AI 路径不管聊得多花哨,最终写入仓库的动作只有一个入口------第二层的写意图。AI 的产出物是"草稿"(内存态 JSON),不是仓库变更;把草稿变成仓库变更的是人在 Review 面板点"确认"后前端发起的 create_req。这个职责切分是整个模块最重要的设计决策。

二、数据模型:git 仓库布局

本地 clone 在 workspace/requirements-agent/requirements-repo(loadRepoConfig 里定义),目录布局:

bash 复制代码
index.json        # 衍生索引(看板数据源)
members.json      # 成员表(SSO 登录自动收录)
requirements/
  REQ-20260811-5yeprx/
    requirement.md    # frontmatter + 正文(含 ## 评论 区块)
    prototype.html    # 可选 HTML 原型

frontmatter 即权威数据。 一个需求的全部结构化字段都在 requirement.md 的 frontmatter 里(RequirementFrontmatter):id / title / status / priority / creator / handler / watchers[] / dueDate / labels[] / hasPrototype / createdAt / updatedAt。status 五态 backlog | todo | progress | review | done(前端列中文名:需求池/待开始/进行中/测试中/已完成),priority 三档 high | mid | low,id 规则 REQ-<yyyymmdd>-<6位随机>(generateRequirementId)。评论不是独立结构,就是正文末尾 ## 评论 区块里 - **[<iso时间>] <帐号>**: <内容> 这样的行,countComments 按行前缀计数。

index.json 是纯衍生数据,这是冲突自愈能成立的根基。 IndexData = version / updatedAt / requirements[],每条 RequirementRecord 就是 frontmatter 字段加 path / commentCount / hasPrototype(按 prototype.html 是否存在探测)。它存在的原因很实际:看板加载时不该逐个解析几百个 md 文件,读一个 index.json 就够。但它的一切内容都能由 rebuildIndex() 遍历 requirements/ 目录重建------所以 rebase 冲突时如果冲突文件是 index.json,可以直接放弃它、重建它,而不是费劲去 merge(见第三节)。排序统一走 compareRequirement:updatedAt 降序(Date.parse 归一),同秒回退 id 升序。

members.json 是登录行为的副产物。 Member = { account(域帐号,唯一键), name, color },color 按域帐号 hash 取 8 色板(colorForAccount)。没有管理后台,成员靠 SSO 登录时自动 update_members 收录(见第三节)。

三、后端写路径:三段式与冲突自愈

配置与身份。 loadRepoConfig() 从 config/base.yaml 的 requirements 段读 repo_url/repo_branch/token/git_user_name/git_user_email,injectToken() 把 token 拼进 HTTPS URL(oauth2:<token>@host)。操作者身份来自 auth/current_user.json(SSO 登录态)而不是前端传参(readOperator)------这是权限校验的信任根,前端传"我是谁"不可信。

所有写操作遵循三段式:pull --rebase → 写文件 → commit+push 核心是 pullRebase(),它是整个模块最硬核的一段代码,源自一次真实生产事故(commit 5770912,详见第九节):

  1. git pull --rebase 失败,先判断是不是真在 rebase 态(rebaseInProgress 查 .git/rebase-merge|rebase-apply 目录是否存在)。非冲突类失败(网络抖动等)直接抛友好错误,不把 git stderr 甩给前端。
  2. 真冲突时,取冲突文件清单(diff --name-only --diff-filter=U),逐文件 checkout --theirs 然后 add + -c core.editor=true rebase --continue。这里有个反直觉的坑:rebase 语义下 --theirs 指的是"正被回放的本地提交" ,也就是用户刚做的那个动作------所以这条策略的实际效果是"本地动作优先"(源码注释专门写了这个语义反转)。
  3. 如果冲突文件里含 index.json:它是衍生数据,不值得 merge------自愈完成后从 requirements/ 重建索引并单独推一个 chore(req): rebase 冲突自愈后重建索引 提交。这一步解决的是 updatedAt 时间戳造成的"伪冲突":两个人差不多同时操作,index.json 的 updatedAt 字段必然冲突,但这种冲突没有 merge 价值,重建即可。

commitAndPush() 两个细节:支持按路径 git add <path>,避免误提交工作区里不相关的文件;commit 用 -c user.name/email 指定仓库 owner 身份,因为 Git 服务端的 pre-receive hook 强制校验提交者。

intents.ts:六种写意图 + 权限。 WriteIntent = { intent, payload, expectedBaseSha? },六种:create_req / update_req / delete_req / transition / comment / update_members。权限模型:

  • requireOperator():一切写意图要求登录态。
  • assertHandler():update/delete/transition 只有当前 handler(处理人)能做。
  • comment 放宽到 creator/handler/watchers。
  • update_members 不做权限校验------任何人登录后收录自己,天然无害。

各意图里值得记住的实现点:create_req 的 id 防碰撞重试 5 次、watchers 强制包含创建者、默认 status=backlog、正文缺省给「(待补充)」;update_req 里 prototypeHtml 传空字符串等于删除原型;delete_req 是 rmSync 整个需求目录 + index 移除;update_members 里 name 没变就不写(alreadyExists 短路,减少 commit 噪音)。

executeWriteIntent 主入口的乐观锁与重试。 乐观锁:首轮带 expectedBaseSha 时先比对本地 HEAD,不匹配先 pull 一次再比,仍不匹配返回 {ok:false, error:"conflict", latestSha}。重试:MAX_RETRIES=2,可重试错误是四类------conflict / fetch first / non-fast-forward / no such ref was fetched(最后一种是远端引用短暂不可见的抖动);重试前先 rebase --abort 清理可能的残留 rebase 态。注意:前端后来不传 expectedBaseSha 了(乐观锁弃用,见第九节坑二),实际并发兜底靠的就是这套"pull --rebase + 冲突自愈 + 重试"组合。

测试是认真的。 repo-conflict.test.ts 用 bare remote + 双 clone 真实还原了那次生产事故场景,三个用例:index.json 时间戳伪冲突自愈且重建后 JSON 合法、requirement.md 冲突时本地版胜出、无冲突时正常 fast-forward 不产生多余提交。repo.test.ts 覆盖 frontmatter 解析/序列化、id 生成、评论计数、CRLF 兼容。

四、HTTP API 端点与错误翻译

三个需求端点注册在 AdminRouter 里(src/channels/http/admin.ts):

方法 路径 用途 要点
POST /api/requirements/read 读仓库文件(index.json/members.json/需求 md/原型),body {path, pull?},返回 {content, sha}。默认先 pull --rebase(失败不阻断读);pull=0 跳过(write 后本地即最新)。残留 rebase 态拦截返回 409 友好错误 requirementsRead
POST /api/requirements/write 原子提交写意图 {intent, payload, expectedBaseSha?};conflict 返回 409(带 latestSha),其余 400 requirementsWrite
POST /api/requirements/rebuild-index pull 后遍历 requirements/ 重建 index.json 并推送,返回 {sha, count} requirementsRebuildIndex
POST /chat?stream=1 聊需求 SSE(通用端点,agent=requirements-agent,chat_id=req-draft:) src/channels/http/index.ts

read 端点的"残留 rebase 态拦截"值得展开:如果上次写操作的 rebase 自愈失败,仓库会停在 rebase 中间态,此时 index.json 里可能带着 <<<<<<< 冲突标记,前端拿到一 parse 就是天书。read 端点检测到 rebase 残留态时直接返回 409 和人能读懂的错误信息,把"仓库坏了"这件事变成一条可读的报错。

另一个容易被忽略的细节是错误翻译层 (friendlyRequirementsError):需求模块依赖本机 git,用户环境千差万别------git 没装、不在内网/VPN、token 失效,原始 execSync 报错全是天书。这层用正则把三类错误翻译成可操作的提示:"未检测到 Git,请先安装 Git for Windows 后重试"、"无法访问仓库,请检查网络(是否在内网/VPN)"、"requirements.token 已失效,请联系管理员更新"。桌面端产品的报错信息是给非运维用户看的,这一层不是锦上添花,是第一触点。

五、requirements-agent:程序化 seed 的专用 agent

聊需求这条路径用的是一个专用 agent------requirements-agent。它有个特别的来历:它不随 git 仓库分发文件,而是由 seedBuiltinAgents()(src/agent/migrate.ts)在 AgentRegistry.loadAll 时程序化 seed ------agents/requirements-agent/agent.json 已存在则跳过,代码是定义的唯一源。

seed 定义:skills ["requirement-authoring", ...],tools [run_shell, run_command, read_file, write_file, submit_requirement_draft],指定 provider 槽位,shell 启用,file_access_level: system 且 system_allowed_paths 仅限 workspace/requirements-agent/requirements-repo------也就是说这个 agent 的文件权限被精确圈在需求仓库目录里,越不出界。

SOUL 要点:引导用户澄清需求(目标用户/场景/所属模块/验收标准/优先级/上线时间),澄清充分后调 submit_requirement_draft;如果要用 run_shell 直接操作仓库,必须遵守 git 规范(先 pull --rebase、commit message 带 (by <域帐号>));写仓库前读 current_user.json 校验 handler,但草稿不校验------草稿只是草稿。(一个已知的小瑕疵:SOUL 文本里写的路径与实际落盘位置有出入,提示词和实现之间也会漂移------SOUL 是提示,不是契约,改路径时两边都要检查。)

submit_requirement_draft 工具是职责切分的关键。 实现(src/tools/builtin/requirement-draft.ts)注册进全局 builtin 表:校验 title ≤40 字、priority 枚举合法、body 非空,返回值是 JSON 字符串,随 SSE tool_result 事件回传前端。它不写仓库 ------刻意如此。"AI 产草稿、人确认、确定性路径落库"这三段里,这个工具只占第一段。也不设独立的 SSE draft_update 事件,就是复用标准 tool_result 通道(工具头注释说明这是遵循设计文档的决策)。

六、前端看板

页面骨架 (RequirementsPage.vue):单页两视图 board | chat 切换,加四个覆盖层(Review 面板、详情弹窗、新建弹窗、成员弹层)和常驻 FloatingPrototype。KeepAlive 保留 UI 状态,onActivated 静默刷新。store.error 统一走全局 toast。

看板(RequirementsBoard.vue + stores/requirements.ts):五列 + 卡片,卡片上有优先级徽章、labels、创建人/处理人双头像、截止日(isOverdue 逾期标红)、评论数、原型图标。几个设计点:

  • 拖拽是 pointer 事件自实现的(mousedown/mousemove/mouseup + drag-ghost 跟随缩略卡),不用 HTML5 drag API------因为要兼容 macOS 的 WKWebView。位移 >3px 才算拖拽,避免吞掉正常点击。仅 handler 可拖(canTransition,非负责人卡片显示 not-allowed 光标)。
  • 右键菜单:「移至」五列是给管理者的兜底入口(不受 handler 限制),「删除需求」仅 handler,与后端 assertHandler 对齐。菜单带视口钳制,不会在屏幕边缘被截断。
  • 失败重试角标:transition 失败的卡片挂「重试」tag(failedTransitions Map),点击 retryTransition 重发。

store 层的两个关键竞态设计(stores/requirements.ts),这是前端最值得讲的部分:

  • pendingWrites 计数:写操作进行中时,in-flight 的 load 结果直接丢弃。这是"拖完卡片 1 秒后回弹"那个 bug 的根因修复------拖拽做了乐观更新,但之前发出的 load 这时返回了旧远端数据,把乐观更新覆盖回去,卡片就弹回去了。
  • writeChain 串行队列:git pull→commit→push 不是并发安全的,所有写操作排队执行。连带一个细节:连点删除时,后一个操作 pull 下来发现目标目录已被前序操作删掉,会报"需求不存在"------这种情况静默成功,因为用户的意图(删掉它)已经达成。
  • 乐观更新 + rollback:transition/deleteReq 先改内存,失败回滚。写成功后 load({skipPull:true})------write 刚 push 完,本地就是最新,省一次远端往返(对应后端 read 的 pull=0)。
  • IndexedDB 缓存(useReqCache.ts):index/docs/members/drafts 四个 store,以 commit sha 校验失效,无 TTL。缓存让看板秒开,但缓存绝不覆盖 headSha------这是第九节坑二的教训:缓存的旧 sha 覆盖 headSha 会让乐观锁拿旧值去比对,必 conflict。

聊需求 (RequirementsChat.vue + stores/reqChat.ts):体验与主聊天对齐------MarkdownMessage 渲染、流式光标、hover 复制、Enter 发送/Shift+Enter 换行、ModelSelector 复用、空会话给快捷指令。每个需求草稿一个独立会话,chatId = req-draft:<draftId uuid>,「+ 新需求」就是 newDraft() 换个 uuid 重来。支持文档上传:docx/xlsx/txt/md 动态 import mammoth/xlsx 解析后拼进消息(重依赖按需加载)。draft 的捕获点:SSE tool_result 事件里 name === "submit_requirement_draft" 时 JSON.parse 出 draft,带 prototypeHtml 时浮窗已开则刷新、未开则自动弹出。

Review 面板 (RequirementReview.vue):AI 产出草稿后的人工确认闸口 。左栏可编辑标题/优先级/目标上线/处理人/标签/关注人/正文,右栏原型「预览/源码」双 tab。handler/watchers 不由 AI 产出,默认当前用户,在这个面板里指派(修过一个"草稿权限误判":草稿不是写仓库,不需要 handler 校验)。点确认 → createReqFromDraft → 成功后清 draft。

原型窗 :两处渲染 prototypeHtml,统一 iframe :srcdoc sandbox="allow-scripts"(Review 面板与 FloatingPrototype.vue)。SOUL 里明令原型 HTML 禁用 localStorage/fetch/XHR------同源 API 在 sandbox 里本来就不可用,写了也是运行时报错。FloatingPrototype 是可拖动浮窗(标题栏 pointer 拖动 + 视口钳制,:key="html" 变更即重渲染),420x560 固定尺寸。

七、与系统其他部分的关系:最大化复用

这个模块专用代码只有 src/requirements/ 两个文件约 780 行 + desktop 一组看板组件,其余全是复用:

  • 消息总线复用 :聊需求不开专用链路。/chat 把 HTTP body 规范化成 InboundMessage(channel:"http"、chat_id、user_id 取请求体值缺省回退操作系统用户名)push 进统一总线,由 Dispatcher 路由到 requirements-agent 的 LLM loop;响应经 replyTargets 回 SSE。同 session_key 并发返回 409。
  • 会话持久化复用 :req-draft 会话与主聊天同样落盘 data/<agent>/sessions/<key>.jsonl,受 sessionKey chokepoint 的统一路径安全约束(第九节坑五就是这个 chokepoint 引出的)。
  • 身份体系 :操作者身份全链路取自 auth/current_user.json(SSO),后端 readOperator 与前端 auth store 同源。成员表 members.json 由登录行为自动积累,不需要管理后台。
  • agent 基础设施:requirements-agent 是标准 agent(agent.json/SOUL/skills/file_access 护栏全套),submit_requirement_draft 走标准 ToolRegistry,seedBuiltinAgents 挂进 AgentRegistry.loadAll。
  • 配置体系:requirements 段进 config/base.yaml 统一 schema 校验,token 分发与 marketplace.token 同模式。
  • 前端基础设施:SSE 消费(sseChat.ts)、MarkdownMessage、ModelSelector、toast、KeepAlive 全部复用聊天页既有件。SSE 消费端还有个健壮性细节:单行 JSON 解析失败跳过而不是杀流。

八、设计取舍:为什么这么做

为什么用 git 仓库当数据库,而不是起个服务 + SQLite/MySQL?

核心是部署模型和团队规模的匹配。这个看板的使用者是几个人规模的小团队,每人一个桌面端,没有也不想要一个常驻的中心化服务------起了服务就要解决部署、备份、鉴权、可用性一连串问题。git 仓库当存储层,同步语义(pull/push/冲突解决)是现成的,版本历史天然自带(每次变更都是一个 commit,谁改的、改了什么、什么时候改的全可追溯,还能回滚),远端 Git 就是现成的"服务端",备份和权限都复用 Git 托管平台。代价也很清楚:并发写要靠 pull --rebase + 冲突自愈兜底,没有数据库的事务和索引,读性能靠 index.json 这个衍生索引补偿。这个取舍在"小团队 + 低频写 + 内网"的场景下是赚的,放到几十人高频写的场景就不成立了------所以第三节那套冲突自愈和重试机制才必须做得那么认真,它们是这个架构能成立的前提。

两个人同时操作,冲突怎么解决?

分三层。第一层是预防:前端 writeChain 把本机的写操作串行化,后端三段式 pull --rebase 保证写前拿到最新远端。第二层是自动解决:rebase 真撞冲突时,pullRebase 逐文件 checkout --theirs(rebase 语义下 theirs 是本地正被回放的提交,效果是后写者的动作生效),index.json 这种衍生数据不 merge 而是重建,然后 rebase --continue 自动完成。第三层是重试兜底:conflict / fetch first / non-fast-forward / 远端引用抖动这四类错误最多重试 2 次,重试前 rebase --abort 清理现场。三层之外还有两道护栏:自愈失败留下的残留 rebase 态由 read 端点拦截成可读的 409,前端乐观更新失败会回滚并挂重试角标。乐观锁(expectedBaseSha)曾经是第四层,但因为缓存会污染客户端手里的 base sha,已经弃用------现在的思路是"冲突解决集中在信息最全的服务端,不在客户端做版本比对"。

AI 在这个模块里的角色边界在哪里?为什么不直接让 AI 写仓库?

AI 的角色严格限定在"澄清需求 + 产出草稿",落库动作永远走确定性 HTTP 写路径。这么切的理由有三个:一是写的可靠性------创建需求要生成防碰撞 id、强制 watchers 含创建者、校验权限(handler/creator)、维护 index.json,这些都是确定性逻辑,LLM 来做只会引入不必要的失败模式和 token 消耗,模块头注释把这句话写明了;二是责任闸口------AI 产出的草稿可能标题起得不好、优先级判断错、handler 根本不是 AI 能定的(它不知道团队分工),所以 handler/watchers 刻意不由 AI 产出,留给人确认时在 Review 面板里指派,确认那一刻才调 create_req;三是审计干净------仓库里每个 commit 都对应一次人的确认动作,没有"AI 自己写的"提交混在里面。SOUL 里允许 agent 用 run_shell 直接操作仓库,但那被 file_access 护栏圈死在需求仓库目录,且要求遵守 git 规范,属于能力兜底而非常规路径。

submit_requirement_draft 为什么不直接落库?返回值怎么到前端的?

工具本身只做校验(title ≤40 字、priority 枚举、body 非空)然后返回 JSON 字符串,不写仓库------这就是"AI 产草稿、人确认、确定性路径落库"职责切分的落点。传递通道是复用而不是新建:工具返回值随标准 SSE tool_result 事件流到前端,前端 reqChat store 监听 tool_result 里 name === "submit_requirement_draft" 的事件,JSON.parse 出 draft 存起来,弹出 Review 面板。刻意不设独立的 draft_update 事件------SSE 事件类型越少,前后端契约越简单。人确认后,前端调 createReqFromDraft,走的是和手工新建需求完全相同的 /api/requirements/write + create_req 意图,两条来源在写路径上完全归一。

requirements-agent 为什么不随仓库分发 agent 文件?

它由 seedBuiltinAgents() 在 AgentRegistry.loadAll 时程序化 seed:磁盘上 agents/requirements-agent/agent.json 已存在就跳过,否则按代码里的定义落盘。这么做的动机是"代码是唯一源":agent 文件随 git 仓库分发时,改过定义的人要记得同步改文件,两边漂移是迟早的事;seed 进代码后,升级就是升级代码,老用户磁盘上已有的 agent.json 保留(跳过逻辑),新用户开箱即有。seed 定义里最有讲究的是权限圈定:file_access_level: system 且 system_allowed_paths 只列需求仓库目录,这个 agent 能读写的文件被精确限制在内,就算 LLM 行为跑偏也越不出这个界。

index.json 既然能重建,为什么还要提交进仓库?

因为重建的成本在读路径上付不起。看板加载时要列出全部需求的标题/状态/优先级/评论数,如果每次都遍历 requirements/ 目录解析几百个 md 的 frontmatter,打开看板就是几百次文件 IO + 解析,冷启动完全不可接受。index.json 把这个成本摊到写路径上:每次写操作顺带更新索引,读路径只需要一次 pull + 一次文件读。这是典型的"写时算、读时取"的物化视图思路,和数据库的物化视图/缓存同源。它进仓库而不是存本地,是因为它是团队共享的读模型------每个人 pull 下来就能直接渲染看板。而"可重建"这个性质不是用来替代它的,是用来兜底冲突的:rebase 冲突时衍生数据没有 merge 价值,直接重建最干净,这也是冲突自愈敢对 index.json 放手的原因。

九、踩坑记录:生产事故与修复

这些坑每个都有具体的 commit 和修复逻辑,是这套架构最真实的成本记录。

坑一:rebase 冲突自愈(commit 5770912,生产事故)。 多人同时操作看板,两个人的写几乎同时 push,后 push 的人 pull --rebase 时撞冲突。最初实现没有冲突处理,rebase 挂在中间态,后续所有读写全挂,前端拿到的 index.json 里带着冲突标记,parse 直接炸出天书。修复就是第三节讲的 pullRebase 三步:识别真 rebase 态 → 逐文件 checkout --theirs(注意 rebase 下 theirs 语义反转,theirs 是本地正被回放的提交,效果是"用户刚做的动作优先")→ index.json 不值得 merge 就重建并单独推送。配套 repo-conflict.test.ts 用 bare remote + 双 clone 真实还原事故场景做回归。外加 read 端点的残留 rebase 态拦截(409 友好错误)兜底自愈失败的情况。

坑二:乐观锁 expectedBaseSha 弃用(commit c452859)。 原本前端写操作时传 expectedBaseSha=headSha 做乐观锁,理论上能提前发现"我基于的数据过期了"。但实际撞上:load() 走 IndexedDB 缓存秒开时,缓存里的旧 sha 覆盖了 headSha ,于是第二次拖拽拿着旧 sha 去比对,必 conflict------用户什么都没做错,就是连续拖了两张卡片,第二次必失败。修复方式很有意思:不是修缓存,而是前端干脆不传 expectedBaseSha(后端乐观锁逻辑保留但不再被触发),真并发依赖后端的 pull --rebase + 冲突自愈 + 重试兜住。同时 load 缓存不再覆盖 headSha。教训:乐观锁的正确性依赖"客户端手里的 base 版本是准的",而缓存层恰恰会破坏这个前提------当保证不了前提时,把冲突解决下沉到服务端(那里信息最全)比修客户端的缓存一致性更可靠。

坑三:CRLF 解析坑(repo.ts 的 parseRequirementMd / parseSimpleYaml)。 Windows 上 git autocrlf=true 会把工作区文件的 LF 转成 CRLF,而 frontmatter 解析的正则严格按 \n 匹配,于是一整类文件被判"缺少 frontmatter",transition 全挂。修复:解析与 YAML 行匹配都容忍 \r。前端 gitClient.ts 有对齐实现(两处需同步,注释专门提醒)。跨平台文本处理,"行尾不一定是 \n"应该成为肌肉记忆。

坑四:看板回弹竞态(commit c3a2a00 同期修复 + 1e1c38a 回归修复)。 症状:拖完卡片约 1 秒后卡片弹回原列。链路:拖拽做乐观更新(内存里卡片已换列)→ 写请求在飞 → 之前发出的 load 此刻返回旧远端数据 → 覆盖内存 → 卡片回弹 → 写请求成功后再次 load 才纠正。修复:pendingWrites 计数,写操作进行中时丢弃 in-flight 的 load 结果。但这个修复引入了一个回归:新建需求后,write 自身触发的刷新也被竞态守卫挡住了,新建的需求不显示------所以 skipPull 路径(write 后的本地刷新)要豁免这个守卫。修竞态的典型教训:守卫条件要能区分"别人的 stale 数据"和"我自己 write 后的合法刷新"。

坑五:chatId 冒号(commit 3d4813e)。 聊需求的 chatId 格式是 req-draft:<uuid>,但 sessionKey 工厂(src/session/key.ts,所有会话 key 的唯一构造点)统一拒绝 :,导致所有历史格式的 req-draft 会话全部 400。修复做了个精细的切分:agentName 段保持拒冒号(因为 : 是 session*key 的分隔符,sessionsDirFor 按 split(":")0 取目录),chatId 段放行冒号、只拒路径字符------落盘文件名本来就有 replace(/:/g,"* ") 消毒,安全性不缺。教训:chokepoint 加严格校验时要先排查存量数据格式,否则一次"安全加固"就是一次存量全挂。

坑六:删除体验的完整闭环(c5ff57a/5d7eba3)。 右键菜单入口 + Teleport 确认弹窗(明确告知"文档与原型一并从仓库移除,不可恢复")+ 乐观移除/失败回滚 + 权限仅 handler。删除是破坏性操作,这个闭环把"入口可达性、确认充分性、失败可恢复、权限一致性"四件事都补齐了。

坑七:token 随仓库分发(ed199e1)。 内网 Git token 放进 config/base.yaml 而不是 .env,接手者 clone 即用,零配置。这是刻意的取舍:与 marketplace.token 同模式,换来部署便利,代价是 token 明文进 config(见已知局限)。

十、已知局限(诚实清单)

  1. 并发兜底靠重试,不靠锁。 乐观锁 expectedBaseSha 弃用后,真并发冲突完全依赖后端 pull --rebase + 冲突自愈 + 最多 2 次重试。冲突解决策略是"后写者胜"(rebase theirs 语义),同一条需求两个人同时改正文,先写者的修改会被静默覆盖------有版本历史可查可回滚,但用户当下不会收到任何"你的修改被覆盖了"的提示。对低频小团队够用,但这不是数据库级的并发语义。
  2. token 明文进 config。 Git token 放在 config/base.yaml 随仓库分发(坑七的决策,与 marketplace.token 同模式),换来接手零配置,代价是任何能拿到 config 的人就拿到了仓库写权限。内网 + 小团队场景可接受,严格安全模型下应该走 .env 或密钥管理。
  3. 成员表无管理后台。 members.json 靠 SSO 登录自动积累,只能加不能改不能删------改名字、改颜色、移除离职成员都要手工编辑仓库文件。update_members 还不做权限校验(设计上任何人收录自己无害,但也意味着谁都能往成员表里写)。
  4. "移至"右键菜单绕开 handler 限制。 前端右键菜单的「移至」是给管理者的兜底,不受 handler 限制,但后端 transition 意图的 assertHandler 只认 handler------前后端的权限口径并不完全对齐,菜单项的可见性是前端自觉,不是一个强制的"管理员角色"概念(系统里根本没有角色体系)。
  5. 单实例假设。 readOperator 读本机 auth/current_user.json、本地 clone 只有一份,整个模块假设后端是单实例桌面端 sidecar。多实例部署时本地 clone 之间会互相覆盖,架构不成立。
  6. 读路径的 pull 失败不阻断。 read 端点默认先 pull --rebase,但 pull 失败(比如断网)不阻断读,返回的是本地旧数据------看板会静默显示过期内容,只有 sha 变化能间接发现。离线可用是特性,但"看到的是旧数据"对用户不透明。

小结

需求管理模块 = "git 仓库当数据库 + 确定性 HTTP 写路径(pull --rebase 三段式 + 冲突自愈 + 重试)+ AI 只产草稿、人确认落库":存储与同步复用 git,index.json 做物化视图补读性能,权限信任根在 SSO 登录态而非前端传参;前端用 pendingWrites + writeChain + 乐观更新回滚三件套解决"远端慢而 UI 要跟手"的竞态;requirements-agent 由 seedBuiltinAgents 程序化 seed、文件权限圈死在仓库目录,submit_requirement_draft 工具只校验不落库,草稿经 Review 面板人工确认后归一到 create_req 意图写入;全模块专用代码仅 src/requirements/ 约 780 行 + 一组看板组件,总线/SSE/会话/身份/agent 基础设施全部复用。

如果这套架构只带走一条经验,是这句:AI 的产出停在"草稿",把"写入"留给确定性的代码和人------系统里每一类操作,先想清楚它需要的是概率性的智能还是确定性的可靠,别让 LLM 替代码做代码更擅长的事。

相关推荐
龙亘川18 分钟前
AI + 人社新范式:智慧人社系统如何为民生治理数字化难题提供帮助
人工智能·智慧城市·数据可视化·政务
水如烟19 分钟前
孤能子视角:蓝星文明篇·市——交换机制的运行化:从偶发交换到日常运行的制度化
人工智能
技灵AI23 分钟前
Wan 3.0 API怎么做多参考商品视频?从图片、视频、音频分工到30秒交付
人工智能·prompt·aigc·音视频·wan 3.0
武子康24 分钟前
CLAUDE.md 引用 AGENTS.md 后,两边真的读到同一套规则吗?
人工智能·llm·agent
动恰客流统计35 分钟前
线下零售数字化浪潮下,客流统计的3个核心发展趋势
大数据·前端·人工智能
johnsong41 分钟前
效率的边界:当推理突破遇见语言革命
人工智能·语言模型
染指111042 分钟前
119.Agent-LangChain核心组件-Runtime运行时
人工智能·langchain·agent
DevNo1 小时前
英文会议音视频转中文纪要:我近期的几款工具使用记录
人工智能
别动我齐刘海1 小时前
ROS2 Jazzy + C++ 实战路线——基础学习2
c++·人工智能·vscode·python·学习·机器学习·机器人
江苏久众新视1 小时前
SOP-AI视觉检测实战:从“专人专用”到“产线普适”的工程落地分享
人工智能·视觉检测