Centrol 代码深度分析:Agent 怎么搭的、哪里是毛线团、业务流程与代码流程对照

调研日期:2026-11-05。对象:repos/CentrolAI main 分支(579 次提交,最后提交 2026-11-05,主要作者 Gloridust)。
起因:合伙人在 Lark 上的八轮反馈(Feedback 1.0 → 8.0,2026-08 至 2026-11-04)里,大量问题反复出现、改了又回来;开发方自己的评价是「不够智能、不够灵活、很难跑通」。
方法:先读仓库自己的架构文档与 37 份 ADR、46 份踩坑笔记,再分五路只读通读源码(Agent 内核、旧 lib/ai 层、数据层与状态机、九步业务流程逐步追代码、Funyard 工程做法对照),每条结论都对到文件和行号。文档与代码不一致时以代码为准。
读者:我们自己(决定要不要在这套代码上继续做)和合伙人(理解为什么反馈改不动)。

0. 一页结论

这个 Agent 的「形状」是对的,但「骨架」没有。 2026-10-16 的重构(ADR 0026)把对话 Agent 做成了常驻、一步一落库、能被事件叫醒的会话,这一步解决了「Agent 只活 95 秒」的根本问题,方向和业界(Anthropic Managed Agents、pi)一致。但它没有替换旧层,而是在旧层上加了一层「名字翻译 + 权限判定」:真正的业务逻辑(匹配 977 行一个函数、梯子 985 行一个函数、工具执行 31 个 case 的 switch、SMTP 投递 607 行)仍留在 2026-08 起堆出来的三个巨型文件里。

「毛线团」不是风格问题,是七件结构性的事叠在一起:

#根因一句话证据
1没有领域层,没有状态机创作者 13 态在 10 个文件 33 处裸写;两张核心表各 44–45 列,状态、子状态标记、缓存混在一行;「在哪一步」每拍从 ≥10 列 + 3 张表重新推导
2新内核叠在旧层上,三层工具体系模型看 toolbox-spec(29 个)→ toolbox 翻译(13 个自写、14 个转发)→ 旧 runTool(31 case);旧 tools.ts 949 行只剩 schema 被借用;新旧两层用 await import 绕循环依赖
3横切关注点没有落点权限 decide() 散在 12 个文件、事件写入 90 处/17 文件、扣费 65 处/15 文件;代码注释自承「七个地方要记得写,漏一个不报错」
4同一件事多份实现金额提取 5 份、排信入口 8 处、成交 3 条路、挑人 3 套、确认卡 4 套 key/payload/执行路径、审批双轨、重试黑名单两套、梯子两套编号
5队列表和事件表身兼数职agent_runs 同时是队列、锁、进度表;claim_next_run 整函数重写 8 次、WHERE 11 个条件;确认卡开/关靠扫 thread_messages,9 处各扫一遍、limit 各异
6测试钉的是源码文本,不是行为94 个测试文件里 88 个用 readFileSync 读 src/,3,009 条 assert.match 匹配源码正则;没有 CI;按客户反馈轮次命名(test-feedback5.mjs…)。任何重构都让几十个测试变红
7开发模式是「按反馈逐条打补丁」151/579 次提交碰 ≥10 个文件;每轮审查 40–90 条问题;每条反馈同时动工具、梯子、提示词三处;runner.ts 73 个、advance.ts 56 个 ⚠️ 事故注释

对我们的含义: 它不是一个能在上面「继续加功能」的代码库,而是一份非常详细的、用血换来的需求与坑位清单(37 份 ADR + 46 份踩坑笔记 + 37 份评测场景)。真正值钱的是这些知识和几块做对了的部件(见 §6),不是这 8.8 万行 TypeScript 本身。


1. 这个 Agent 是怎么搭的

Centrol 代码全景:一个新内核叠在一整套旧层上

1.1 四个「大脑」与它们的分工

文档里说「一个常驻的活动 Agent」,代码里实际并存四个决策者,互相靠事件和数据库列沟通:

决策者在哪谁触发决定什么用什么和别人沟通
常驻活动 Agent(新)lib/agent/loop.ts用户说话(turn)、事件叫醒(wake)、点卡后续跑(approval)建活动、改设定、挑人、给谈判下指令、回答用户工具调用;给梯子设参数(hold_until / keep / selection_notes);事件日志
驱动器梯子(旧)lib/ai/advance.tscron 每 120s 心跳(空转 600s)+ 每个任务跑完 chainAdvance放行积压、发外联、挑人、追信、放手、补人读 Agent 设的列;出确认卡(propose.ts);例外时 requestWake 叫醒 Agent(宽限 30 分钟)
谈判子 Agent(旧)lib/ai/agents/negotiate.ts + runner.runNegotiate每封来信一个 negotiate 任务回信措辞、成交/回绝/升级判定读 Agent 写的 directive;写事件 escalated(叫醒)/ deal_closed(不叫醒)
13 种单次任务lib/ai/runner.ts HANDLERS队列匹配、写外联、审片、复盘、摘要、解析文件、拉网盘链接…写表 + 写事件

ADR 0026 承诺的「谈判 → 主 Agent 的结构化报告 negotiation_report」在代码里不存在,替代物是事件旁路:只有升级、匹配完成、审片完成会叫醒主 Agent,成交和回绝不叫醒,主 Agent 要等下一次用户说话时才从事件日志里读到。

1.2 一轮对话(turn)端到端

浏览器 POST /api/copilot                         app/api/copilot/route.ts
 ├ requireOrg(余额 ≤ 0 直接 402)→ 用户消息先落库 append_thread_message
 ├ beginUserTurn:insert agent_runs{kind:turn, status:running}      lib/agent/store.ts:234
 │   锁 = 迁移 0061 的部分唯一索引 agent_runs_one_live_turn(一个对话同时只有一轮)
 │   插不进去 → 接手排着的那行 / 回 {queued:true}
 ├ ReadableStream(ndjson) + runTurn({inline:true, deadline:+95s})   lib/agent/loop.ts:112
 └ after():compactCopilotThread 折叠长对话进 context_digest

runTurn 主循环,每圈:
 ① 查 threads.agent_cancel_at(真的能停)
 ② 上一步没执行完的工具 → executeCall
 ③ 拼上下文:loadLiveState(实时状态块)+ loadLog(thread_messages)+ 最近 6 轮 agent_steps
    → projectLog(按模型「当时看到的顺序」重排;补 user 结尾)
    system = agentSystemPrompt(≤4k)+ context_digest,<now> 贴在最后一条消息末尾
 ④ streamWithTools(lib/ai/client.ts:888)边流边推给浏览器
 ⑤ appendStep(role:assistant, tool_calls) → 回 ①

executeCall(loop.ts:400):
  effectOf(tool)(read / external / config / money / delete …)
  → userSaidIt 正则 → gateRule(效果 × 来源 × 是否读过外部文字)→ run / ask / classify
  → classify 时调一个只看用户原话的分类器
  → ask 时 postCard(approval_requested 事件)
  → run 时 executeAgentTool(lib/agent/toolbox.ts)→ 多数转 runTool(lib/ai/tool-exec.ts)
    部分直调 lib/data/actions,部分直接 .from() 写表
  → appendStep(role:tool)

收尾:appendMessage(assistant) + chargeFeature(幂等键 turn:<run>)+ finishRun
超时:handoffRun(status→queued,attempts 清零)→ cron 认领 → runner HANDLERS.turn
     → withSystemActor(无用户会话时用「系统执行身份」)→ runTurn(inline:false, 240s)
     已有结果的 tool_call_id 不重做

做对的部分:一步一落库、唯一索引当锁、tool_call_id 去重、requeueRun 合并排队行,这四件事真正解决了「95 秒」问题。gate-rules / consent-rules / projection / wake-rules 是纯函数,能裸 node 测。withSystemActor 让被叫醒的 Agent 和人点按钮走同一组函数,没有另写一套 worker 版。

但 loop.ts 也承担了本该在协议层或网关层解决的「模型行为矫正」:按书写系统压流(中文里闪英文)、整段回复再调一次模型翻译(fixReplyScript)、模型说了要做没做就替它排任务(keepBriefPromise)、叫醒时和上一条没答的问题两字一组重合 ≥55% 就删掉(dropRepeatedQuestion)。主循环 56 个 if、12 个出口;lib/agent/ 里带 2026-10 日期的事故注释 109 处,这个目录 3 天内 31 次提交。

1.3 被叫醒(wake)与驱动器梯子

1.4 谈判子 Agent

文档说「四道闸:detectEscalation → complete → screenMessages → decide」。代码实际是:入口升级检测(仅非跟进、非人工改稿)→ 最多 2 轮工具查资料 → 生成 → WAIT 判定 → 截断判定 → 风格闸 → 繁简转换 → 单号/人填价核对 → agreedFee 分支(内含 decide)→ maxOffer 分支 → 跟进分支(内含 decide)→ 主 decide。blocked() 17 处、decide() 3 处分散在三条分支里。negotiation-context.ts 拼输入时顺手写库(更新 quote、first_ask)。

护栏做对了:价格上限、让步次数卡在 policy.ts 判定表和出参校验里,不指望模型遵守;「只有 SMTP 成功才算寄出」;回信按人设工作时段排 deliver_at。

1.5 队列、邮件、计费


2. 业务流程 ↔ 代码流程

业务流程九步与代码入口、写表、事件、反馈根因的对照

下表每步给出:触发 → 入口与链路 → 写哪些表/状态 → 写哪些事件 → 界面怎么知道 → 已知问题。路径相对 src/lib/。

步触发入口与调用链写表 / 状态事件界面已知问题
① 新对话 & 建活动用户在 /c/[threadId] 输入(可用「/」意图菜单)api/copilot → agent/loop.runTurn → toolbox campaign_create → ai/tool-exec create_campaign(216 行);模型按 playbooks.campaign_setup 问六项 + 品牌名campaigns、guardrails(max_offer = 预算 ÷ 人数)、threads.title、agent_runs(match, strategy)campaign_created流式文本 + effect:campaign_created 刷右屏硬闸:品牌名必填、userConfirmedBrief !== true 不建、一对话一活动。CampaignFormDrawer.tsx 845 行只剩总览里「编辑 Brief」在用;agents/brief.ts 全仓无调用(死代码)
② 匹配建活动自动;右屏按钮;Agent creators_find;梯子 ⑥ 补人runner.runMatch(977 行):取池(分页全量、排除别活动联系过的)→ hard-conditions(均播/互动率/平台/Shorts/品牌号)→ 品类判据 → agents/match.scoreCandidate(市场、层级为剔除闸,未知的 hard_unverified 只排后)→ 不够就 pool-topup(EasyKOL → HypeAuditor)→ selectStrict 不放宽 → brand-check 模型判品牌号 → 写理由campaign_influencers upsert shortlisted + match_score / match_reason / hard_unverified;已推进的行只刷分不动状态pool_topup、match_finished(from=agent 或一个新人都没有才 wake)TalentMatch.tsx;useRunningJobs 每 3s 轮询 agent_runs市场闸只看 primary_market,老数据全空;platforms 为空的行直接过平台闸;品牌号只判这一轮要入选的、每轮 ≤150;「点了没反应」是入队即返、按钮几百毫秒就松开
③ 挑人 & 写外联右屏勾选 confirmShortlist;梯子 ③ curateStep(autopilot 自动、其余出 curate:<id> 卡);Agent outreach_sendrunner.runOutreach:人设未设 → run_failed 退出;只取 shortlisted && excluded_at null;取邮箱;有模板走 outreach-template 填空,否则 agents/outreach 写;8 并发 240s,超时剩下的再排一个任务status=draft_ready + outreach_copy / subjectoutreach_sent(写好 n 封等放行 / 自动排队)OutreachApproval.tsx 一行一位看板卡只列有邮箱的(无邮箱的只在文案附注);旧卡无 rowIds 时点一下发全部;右屏「全部发送」默认全选
④ 发信人点卡 / autopilot 自动executeConfirmed send_outreach → actions.sendOutreach(原子占位 outreach_queued_at)→ messages(deliver_at) 按 pacing.spreadOutbound 错开 3–9 分钟且落在人设工作时段 → cron deliver → runner.deliverDueMessages:收尸 → 取到点的 30 封 → 暂停活动推后 → 阻塞判定(没邮箱/没发信邮箱/退信/配额)→ 组信 → SMTP → 成功才写 deliveredmessages.delivery=sent / sent_at / email_message_id;首封把行改 status=sentapproval_resolved、run_failed(寄不出的合并一条)右屏「寄不出」tab「queued to send 卡三分钟」= deliver_at 错开 + cron 每分钟 + 每拍只认领 35s 内的;kickDelivery 在 cron 上下文无 origin 时静默;Agent 看到的工具回执是「排进队列」不是寄出
⑤ 收信 & 谈判cron inbox 每分钟 IMAPmail/route.routeInbound → resolveTarget(线程头限本组织;否则发件人邮箱,只认 outreach_sent_at 非空且非 declined 的行)→ 认不到记 unrouted 只留信头 → 插 messages(sender=creator) → 立刻 status=replied → 附件落文件区、网盘链接排 fetch_link → 排 negotiate。runner.runNegotiate:等 fetch_link → 拼上下文 → tryCloseOrDecline(置信 ≥0.8 → decide(close_deal):deny 升级 / ask 出 deal: 卡 / auto 直接 agreed + 出合同)→ agents/negotiate → 三支:升级(defection_reason + holding line + wake)/ 待批(插 pending_actions)/ 自动寄(scheduleGroup 20 分钟–6 小时)replied → ai_negotiating / agreed;pending_actions;counter_offer / negotiation_rounds / concessions_usedkol_replied、offer_made、escalated(wake)、deal_closed(不 wake)NegotiationDesk.tsx 1,562 行,状态从 status + pending_actions + messages.delivery 三处拼「已回复没进谈判」四条路:落 unrouted、no_session、任务被积分/暂停闸压在 queued、被判 superseded / human_took_over;replied 是入站立刻写的,谈判没跑也显示已回复。「approval 模式 AI 自动回了」:判定表里 reply_chat 在 approval 档是 auto,只有带价/让步/跟进/首封才 ask
⑥ 追信 / 放手 / 补人cron agent → heartbeatAutopilot → enqueueDueAdvances;任务跑完 chainAdvanceadvance.advanceCampaign ⓪–⑦;⑤ nudge_count ≥ 2 放手(approval 档是 auto),否则 48h 追一封(ask 出 nudge: 卡);⑥ 池空补 match;拍末 withdrawStale 收回条件不成立的卡nudge_count、declined、再排 match / negotiateapproval_requested(nudge / curate / release)、autopilot_step对话里的卡卡状态靠扫事件流;Agent 设的 hold_until / no_followup / keep 只在 agentHeld 和投递的 followUpHold 两处被读;例外交给 Agent 只在 ⑥ 池空一处真的做了,其余仍是硬编码兜底
⑦ 成交 & 合同谈判自动 / 人点 close_deal 卡 / Agent creators_update op=close_deal三条路各自 update campaign_influencers 再调同一个 data/deal.settleNegotiation;files/contract.issueContract 出 docx → files(kind=contract) → messages 带附件 → 投递寄出时写 contracts.sent_atagreed_fee、status=agreed → signed(唯一带前置守卫 .eq("status","agreed") 的写入点在 files/extract.ts)、contractsdeal_closed、document_sent、contract_signed谈判台三条路写事件、openContentSlot 各写各的;曾按未寄出的更高报价成交(counter_offer 是生成那一刻写的)
⑧ 交片 & 审片邮件附件 / fetch_link 拉网盘 / 界面或 Agent content_update op=submit共用 content/submit-video.attachVideoToRow → content_pieces(in_review) → runner.runReview:汇总要求(合同 > 谈成条款 > brief > 活动)→ ffmpeg 压审核版 → agents/review 整条给 Gemini → content_reviewscontent_pieces、content_reviews、content_framescontent_arrived、review_ready(wake)内容 tab「说没视频」:Drive 链接需配 key 且公开才自动拉;YouTube / 其他 host 不拉;未到成交阶段的链接不算视频;read_content_review 只认 SUBMITTED 状态,文件区有视频但没 submit 时回「还没有人交内容」
⑨ 复盘 & 计费成交后自排 / 人点runner.runRetro(节省由代码算、复盘由模型写);metering/context.chargeFeaturecampaigns.retro_report、usage_events、credit_lotsretro_ready、credits_exhausted复盘 tab;对话里「积分用完」一句「一直说没额度」:余额判定 6 处(requireOrg 402、enqueue 入口、claim_next_run 静默不认领、任务中透支 5 分、wake 不排、提示词 USED UP),口径不一;claim_next_run 不认领时界面原来显示「正在做」

一拍能串起多少层:cron → runNextJob → claim_next_run(11 条件)→ HANDLERS[kind] → 跑完 chainAdvance 排 advance → 下一拍 advanceCampaign → propose 出卡 / 排 outreach、negotiate、match → 跑完写事件 → requestWake(60s 去抖)→ 下一拍 runTurn。用户看到的「on it」卡住,是这条链里任何一环(积分闸、暂停闸、cron 延迟、kickQueue 无 origin、建活动前没有 campaignId 导致 useRunningJobs(null) 返回空)的统一表现,前端没有「做到哪一步」的机制,只有同一请求内的 liveSteps。

前端怎么知道后台在干嘛:useRunningJobs(campaign.id) 每 3s 轮询只查有 campaign_id 的 queued/running 任务;对话气泡和右屏共用这一份。请求超时 handoff 后,气泡「还在处理」只在 5s 轮询拉到 assistant 消息或 run_failed 事件才消失。卡片由 approval_requested / approval_resolved 事件按 key 重建。


3. 毛线团在哪:七条根因

3.1 没有领域层,没有状态机

3.2 新内核叠在旧层上,三层工具体系,双向依赖

3.3 横切关注点没有落点

3.4 同一件事多份实现

ARCHITECTURE.md §13 自己写「已应验五次」,实测更多:

同一件事份数在哪
金额提取≥5lib/money.ts(主)、agent/directive-guard.ts amountsIn、agent/consent-rules.ts numbersIn、agents/terms-rules.ts numbersIn、style/their-numbers.ts,各自一张 k / 万 乘数表
往 messages 表排信≥8runner.ts:2315(谈判回信)、advance.ts 2 处、dispatch-pending.ts:92、actions.ts 3 处、decline.ts、files/contract.ts;真正寄出只有 deliverDueMessages
成交推进3actions.closeDeal(人点)、actions.acceptOffer(创作者链接自助,链接已停用)、runner.tryCloseOrDecline(Agent 判定)各自 update 状态再调同一个 settleNegotiation
挑人 / 候选过滤3 + 1agents/match.ts 打分 + selectStrict、agents/curate.ts 模型挑、pool-topup.ts 补人;过滤口径 agent/creator-filter.ts 第四份
确认卡4 套驱动器 propose.ts(key outreach:<c> / release:<row> / deal:<row>:<amt>)、旧 tool-exec 的 confirm 效果(被 loop.ts:541 转 postCard,key chat:<action>:<uuid>)、内核 gate 卡(action agent_tool,key agent:<run>:<call>,唯一一套点完会叫醒 Agent)、toolbox 自判卡(复用驱动器 key)。执行路径两条:executeConfirmed 的 action 分支 vs executeAgentTool 白名单 11 个工具
审批双轨pending_actions 表(谈判草稿,8 个文件)与 thread_messages 里 approval_requested / resolved 事件(其余卡);人工动作也分 approve / reject / redraftPendingAction 与 runConfirmedAction / declineAsk 两组
一次性链接 vs 邮件2CHAT_LINKS_ENABLED = false 但 11 个文件仍引用 chat_sessions,谈判锁仍按 chat_session_id 建,收信找不到会话记 no_session
积分提醒「24h 内已提醒过」2metering/stall-notice.ts:42-48、data/credit-actions.ts:62-71

3.5 队列表和事件表身兼数职

3.6 测试钉的是源码文本,不是行为

3.7 开发模式:按反馈逐条打补丁

3.8 两条次要的


4. 合伙人反馈 ↔ 代码根因对照

把 Lark 文档里反复出现的条目映射到上面的根因。「已修」指代码里有对应改动,不代表线上验证过。

反馈(轮次)代码里的根因对应 §3状态
匹配名单地区不对、粉丝量不达标、品牌号还在,「说了三四遍」(2.0 / 6.0 / 8.0)市场闸只看 primary_market 且老数据全空只降序不剔;platforms 为空直接过闸;品牌号只判这一轮要入选的、每轮 ≤150、模型判定;要求只写进 selection_notes 文字、匹配不读3.1、3.711-04 ADR 0037 把要求落成列(hard-conditions.ts),其余未解
建活动「填表」太手动、brief 反复问(1.0 / 3.0 / 6.0)playbook 只能改倾向;userConfirmedBrief 硬闸要再确认一轮;campaign_update 属 config 效果走分类器判授权、判不出就出卡;旧 845 行表单抽屉仍在1.2、3.2部分
点 run match 没反应、on it 卡住、没显示正在 matching、等很久不知道预计时间(2.0 / 4.0 / 6.0)入队即返;进度全靠 3s 轮询 agent_runs,建活动前无 campaignId 轮询为空;handoff 后要等 cron;wake 60s 去抖;积分闸静默不认领2、3.5未解(backlog「匹配的分步进度 P3」)
五封写好只显示一封、一次 approve 触发全部(4.0 / 5.0)卡只列有邮箱的;旧卡无 rowIds 则发全部;右屏「全部发送」默认全选3.4部分(5.0 #9 修了按钮 busy 状态)
queued to send 卡三分钟、说已发完右边没发(4.0 / 7.0)deliver_at 错开 + cron 每分钟 + 每拍只认领 35s 内;kickDelivery 无 origin 时静默;Agent 看到的回执是「排进队列」1.5、210-21 修了卡片误报,根因未解
已回复没进 negotiation;approval 模式下 AI 自动回了说看不到视频(5.0 / 8.0)回信落 unrouted / no_session 或任务被闸压住;replied 是入站立刻写的;判定表 reply_chat 在 approval 档是 auto2、3.1未解
谈判状态看不出、opened 是什么意思(5.0)状态从 status + defection_reason + pending_actions + messages.delivery 三四处拼3.1、3.5未解
对话无止尽重复、同一问题问五遍、一个对话框问两个问题(6.0)提示词压不住,改成收尾层确定性删重复问题(dropRepeatedQuestion);「一次只问一个」与「六项一次列完」两条规则冲突1.2、3.710-20 评测 42/42 pass³,线上待验
一直说没额度、充值后还说没额度(6.0)余额判定 6 处口径不一;记忆把 Agent 自己说过的「没额度」记成事实1.510-16 改记忆只记用户的话
外联信太 AI、要用我们的模板(2.0 / 3.0 / 6.0)首封由 writeOutreach 提示词生成;模板信 10-12 起由代码填空(ADR 0025 §5)—已做模板,质量未验
一次发 50–100 人看板撑不住(7.0)OutreachApproval / PipelineBoard 全量渲染;投递每分钟 30 封、外联 8 并发 240s 一批、cron 一拍 40 任务210-21 改一行一位看板,吞吐未解
排重:合作过的自动去掉(8.0)runMatch 取池时排除别活动联系过的—10-21 已做
审片说「没 video」、网盘链接看不了(5.0 / 8.0)Drive 需配 key 且公开;YouTube / 其他 host 不拉;未成交阶段的链接不算视频2部分(10-21 网盘 media kit 可读)

5. 对照 Funyard 的工程原则

Funyard 是我们自己的 Cloudflare Workers + Durable Object 项目,和 Centrol 规模相近(server 约 1.9 万行 TS,不含生成的目录文件)。拿它的 11 条做法逐条对照,不是说 Funyard 完美(它的浏览器端也没复用 protocol 包),而是这些做法恰好各自防住了 Centrol 的某一类混乱。

#Funyard 的做法Funyard 证据Centrol 现状防的是哪类混乱
1契约单一来源,服务端进出双向校验,副本漂移即失败packages/protocol/index.ts;index.ts:686,713;scripts/protocol.mjs:38没有契约层;工具 schema 两份(tools.ts / toolbox-spec.ts);确认卡 4 套 payload三份 schema 各自演化
2分层单向依赖:HTTP 只翻译命令,业务在 Ledger,适配器不认识账本index.ts:54-58;core.ts 不 import accounting / indexagent ↔ ai ↔ data 用 await import 绕循环;路由(runner)里写投递、收尸、计费路由里写业务、业务里回调 UI
3供应商 / 能力是冻结的数据表,不是散落的 ifregistry.ts:32-91 Object.freeze;descriptor.ts:40-154;加 Twelve Data 只碰 4 个文件HypeAuditor / EasyKOL 分工靠 ADR 0021 文字 + runMatch 里的 if;两个数据源「同一个人存两行」(踩坑笔记)加一家供应商改十处
4公开 ≠ 注册:只有带实网验收证据的路线才进目录public-acceptance.ts「假成功」审查一次 9 处;界面「已发出」邮件一封没寄过看起来能用的能力混进产品
5账本原子,状态枚举集中,终态不可再改accounting.ts:98-108;core.ts:44,105,1027,103313 态无转移表、33 处裸写;record_usage 重写 6 次;计费函数曾对外开着(0052 收回)重复扣费、状态互相矛盾
6未知结果只能 pending,终结需证据;幂等靠 request_id + 指纹core.ts:829,1034,1423;adapter.ts:828投递认领曾两拍重叠寄两遍;谈判任务曾回两遍;agent_runs.input 合并无版本检查(backlog P1)重试风暴、盲目重发
7一条 check 跑全部门禁,CI 三 job 全绿才部署package.json:27;verify.yml:102-103无 CI;npm test 跑源码正则;本地直连生产库;「迁移没跑就上线」「迁移先上代码没部署」各一次事故本地能跑与能上线脱节
8自动测试与实站验收分开记账,没做就写没做docs/README.md:73;protocol/README.md:18测试按反馈轮次命名;评测与 e2e 是真的,但 3,009 条源码正则和它们混在同一个 npm test 里合成测试冒充验收
9文档三层有冲突优先级;records 只追加、带版本号docs/README.md:13-18,70ADR 质量很高,但 ARCHITECTURE / AGENT-SYSTEM / 演示测试流程 / AGENTS.md §1.5 全部过时且没有优先级规则文档与代码各说各话
10迁移哈希锁定;回滚只回代码不回数据;存储只做加法production-policy.json;AUTO-DEPLOY.md:31-6269 个迁移幂等执行,但「本地跑过就等于线上跑过」;claim_next_run 整函数重写 8 次一次热修把账本带回过去
11安全在事务内落地:认证代际每请求核验,日志脱敏account-auth-epoch.ts;core.ts:1596-1624service role 30+ 文件;曾有「单账号时代的工具变成全站后门」「任何人拿公开 key 扣任意组织积分」改密后旧会话继续生效、凭据进日志

一句话:Funyard 把「状态」「契约」「门禁」三件事各自收口在一个地方;Centrol 把这三件事分散在每一个函数里,靠作者的记忆和 ⚠️ 注释维持。


6. 还能不能迭代:三条路与建议

6.1 值得保留的资产

资产在哪为什么
领域知识:37 份 ADR、46 份踩坑笔记、8 轮客户反馈落地计划、backlogdoc/dev/三个月真实客户 + 真实邮件 + 真实数据源踩出来的坑,每条有现象 / 根因 / 复现条件;重做时是现成的需求与验收清单
评测与端到端scripts/eval/scenarios.mjs、scripts/e2e/run.mjs、scripts/qa/按结果判不按措辞判;真实模型 + 沙盒对话;故障注入剧本
常驻 Agent 内核的骨架agent/store.ts(唯一索引当锁、requeueRun)、loop.ts 的一步一落库与 tool_call_id 去重、job.ts 续跑解决 Serverless 上长任务的正确形状
权限层的纯函数agent/gate-rules.ts、consent-rules.ts、wake-rules.ts、ai/policy.ts decide()效果 × 来源 × 污染判定;钱的上限硬逻辑;可裸测
谈判出口纪律agents/negotiate.ts 的四道闸思路、pacing.ts、「只有 SMTP 成功才算寄出」对外说话的唯一出口 + 拟人化节奏
匹配的几条规则hard-conditions.ts、match.ts 的「不符合 vs 核实不了」分区、selectStrict 不放宽直接对应客户最在意的反馈
文件区设计lib/files/(一行一版本、确定性解析优先、读不出返回原因不返回空)ADR 0010
数据源调研doc/dev/03-research/(HypeAuditor 的三条限制、EasyKOL、Google Drive 链接、Gmail OAuth 审核)采购决策依据

6.2 三条路

路线做法代价适合
A. 原地整理先把 3,009 条源码正则测试换成行为测试并加 CI;抽状态机(13 态 + 转移表、唯一写入函数);抽「排信」「成交」「确认卡」各一个入口;删 tools.ts、chat_sessions、Poe 补丁每一步都要先改测试再改代码;三个巨型文件要拆;作者在场才可行;估计 4–6 周看不到新功能合伙人决定继续用这套、且原作者继续投入
B. 抽骨架重建保留 §6.1 的资产和数据库里的创作者库 / 邮件记录;新建产品仓,按「契约 → 状态机 → 领域服务 → 工具 → Agent 循环」分层写;旧评测场景作为验收重写 8.8 万行里真正有业务含量的约 2 万行;要先定契约和状态机;旧数据要迁移我们要把它做成自己的产品、且技术团队由我们配
C. 只保留知识把 ADR / 踩坑 / 反馈 / 评测场景收成需求文档,代码只当参考,按我们的产品叙事(docs/strategy/产品叙事_v1.md)从策略采集用例起步丢掉三个月的代码;但保留了三个月的认知产品形态要变(比如插件、CLI、agency 自用优先)

6.3 建议

  1. 不要选 A 让它「再修一轮」。 七条根因里六条是结构性的,每轮审查几十条、修完下一轮又几十条,这个曲线已经持续三个月。再来一轮反馈 9.0,大概率还是同一批问题换个地方出现。
  2. 无论 B 还是 C,先做三件收口的事再写任何功能: 一份工具 / 事件 / 确认卡的契约(像 Funyard 的 packages/protocol);一张创作者状态转移表和唯一的写入函数;一条 CI 门禁(行为测试 + 迁移校验 + 密钥扫描)。这三件事做完,剩下的功能才能「加一个不碰另一个」。
  3. 把 doc/dev/ 当需求源,不要当代码注释读。 建议 PM(product-research-agent)把 46 份踩坑笔记和 8 轮反馈收成一份「网红营销 Agent 的硬规矩清单」(例如:只有 SMTP 成功才算寄出;钱不替用户编;不符合硬条件不进名单;秒回等于穿帮),这份清单是我们产品的验收标准,和代码无关。
  4. 和合伙人沟通时的说法:不是「代码写得差」——注释、ADR、踩坑笔记的质量在同类项目里是高的;是「两周 demo 的约束下做出的代码被当成产品跑了三个月」,约束从没被重新审视。决定下一步前先定:这是继续做 demo,还是开始做产品。

附录 A:关键文件地图

文件行数作用评价
lib/ai/runner.ts4,670队列消费者 13 种任务 + SMTP 投递 + 收尸 + 重试整条采集管线、谈判链、投递都在一个文件;73 个 ⚠️
lib/data/actions.ts4,10655 个 server action无领域层;declineAsk 550 行
lib/i18n/dictionary.ts3,571中英文案(另有 vi / id 各 1,714)改动最频繁的文件(169 次)
lib/ai/advance.ts3,017驱动器梯子advanceCampaign 985 行;两套编号;56 个 ⚠️
lib/ai/tool-exec.ts2,058旧工具执行层 runTool 31 case47 处直接写表;仍是唯一执行层
components/campaign/NegotiationDesk.tsx1,562谈判台状态从三四处拼
lib/data/queries.ts1,356读层—
lib/ai/client.ts1,076LLM 客户端真正 HTTP 约 100 行,其余是供应商补丁
lib/ai/tools.ts949旧工具定义运行时九成死代码,测试在读
lib/ai/agents/match.ts925打分与闸「不符合 vs 核实不了」分区是对的
lib/agent/toolbox.ts916新工具执行一半自写一半转发
components/thread/ThreadChat.tsx899对话界面卡片从事件重建;进度靠轮询
lib/mail/route.ts871入站邮件路由认领规则严格,认不到记 unrouted
components/campaign/CampaignFormDrawer.tsx845旧建活动表单只剩编辑模式在用
lib/agent/loop.ts778常驻 Agent 回合循环骨架对;塞了模型行为矫正
lib/ai/agents/negotiate.ts742谈判回信四道闸实为十几个分支
lib/agent/toolbox-spec.ts660模型看到的 29 个工具头注释过时
lib/agent/store.ts584agent_runs / agent_steps 读写锁与合并做得对
supabase/migrations/69 文件 4,759 行40 张表、16 RPCclaim_next_run 重写 8 次
scripts/test-*.mjs94 文件测试3,009 条源码正则

附录 B:数字汇总

项数
源码379 个文件、约 8.8 万行(lib/ai 22,762 / components 20,294 / lib/i18n 8,213 / lib/data 7,886 / lib/agent 5,875 / lib/mail 3,712 / lib/files 3,479 / app 2,952)
提交579(2026-08 191 / 09 226 / 10 160 / 11 2);151 次碰 ≥10 文件;58 条标题含反馈
数据库40 张表、69 个迁移、16 个 RPC、15 个触发器、12 个部分唯一索引(4 个当锁)
两张核心表campaigns 45 列(ALTER 14 次)、campaign_influencers 44 列(ALTER 15 次)
状态写入点campaign_influencers.status 33 处 / 10 文件;无转移表
横切关注点decide() 12 文件;appendThreadEvent 90 处 / 17 文件;扣费 65 处 / 15 文件;supabaseAdmin() 30+ 文件;agent_runs 插入 27 处 / 10 文件
事故注释 ⚠️runner.ts 73、advance.ts 56、actions.ts 38、tool-exec.ts 21;lib/agent/ 带日期注释 109 处
测试94 文件、1,069 用例、3,009 条源码正则断言、88 文件读源码、无 CI
文档37 份 ADR、46 份踩坑笔记、18 份计划、14 份调研、backlog 83 行
审查轮次08-17 假成功 9 处;09-22 体检 40 条;09-23 全面审查约 90 条;10-06 审查二约 48 条;10-16 重构后 16 条踩坑

附录 C:调研方法与局限

提示:点击任意图片可放大查看,再点一次缩回。