agent 经常跑一半就崩。MB 不要求 agent 一次性出完所有产物 —— 你想发就发, 想发几次就发几次。最终是不是定稿,server 自己看 row 状态决定。
MB 在 dispatch 时给 tobatsu 三个 URL:业务正常出料走 /result;
agent 自己知道挂了走 /failed;
runner 层挂掉 agent 没机会发,tobatsu platform 走兜底 /platform-terminal。
agent 出料就发。partial 也行,多次也行。
业务侧明确失败。agent 自己判断 fatal 时主动发。
runner / skill / 平台层挂了,agent 没机会发 /failed。tobatsu 平台代发。
callback URL 由 MB 在 dispatch 时 mint,写进 envelope:
envelope.callback_url、envelope.callback_failed_url、
platform_terminal_callback.url。auth 用 per-run 一次性
callback_token,存在 agent_runs.callback_token。
你需要知道的所有"这次 callback 该怎么处理"答案,都在 run.status
这一个字段里。MB 看到当前状态 + 进来的 callback 类型,决定下一步。
源码:src/routes/runs.ts:768 的 isLateUpdate = (run.status === 'done') —
就这一行 boolean 决定是首发 INSERT 还是 late UPSERT。
逻辑像一个 if 分支:当前 row 状态是 done 吗?是 → 走 late update 分支,UPSERT 同一行, 不动 status;否 → 走首发分支,INSERT 新行 + 翻 status 到 done。
// src/routes/runs.ts · /result handler · creative stage 简化版 const isLateUpdate = run.status === 'done' if (!isLateUpdate) { // 首发:INSERT 新 row + flip status await tx`INSERT INTO creative_boards (...) VALUES (...)` await tx`UPDATE agent_runs SET status='done', result_count=1 WHERE id=${runId} AND status IN ('running','failed') AND callback_token=${run.callback_token}` } else { // 后到:UPDATE 同 source_run_id 行(meta 用 || 合并) await tx`UPDATE creative_boards SET title=..., blocks=..., meta=meta || ${...} WHERE source_run_id=${runId} AND deleted_at IS NULL` // 刷一次 result_count 让 UI 看到当前 live 数 await tx`UPDATE agent_runs SET result_count = ( SELECT COUNT(*) FROM creative_boards WHERE source_run_id=${runId} ) WHERE id=${runId}` }
要点:status='done' 之后再来 /result 不报错、不翻回 running、
finished_at 不动。agent 不需要知道这是"第几次",server 端拿 row 状态判断。
MB 不让 agent 自己生成 idempotency_key。靠三层自然约束兜底:
run 层、task 层、item 层。
URL path 里的 :run_id 必须等于 body 里的 run_id;
Bearer header 里的 callback_token 必须等于
agent_runs.callback_token。三对一不齐 → 拒。
pinTaskIdAgainstStored:body 里的 task_id 必须等于
agent_runs.tobatsu_task_id(如果已经写过)。
首发时还没写就用 bindTaskIdIfMissing 把 task_id 钉进去,
防 dispatch 返回慢于 agent 第一次 callback。
不用客户端 UUID,用 row 的业务自然键。
hot_materials 走 (source_slot_id, source_url),
creative_boards / deliveries 走 source_run_id。
而且 UPSERT 的 update 段加 WHERE existing.source_run_id = EXCLUDED.source_run_id,
跨 run 写同一个 URL 时变 no-op dedup,retry-run B 改不到 run A 的 row。
这是我第一次给 mai 出建议时栽的跟头 —— 把 crawl_daily
的语义当成 MB 通用。其实只有它是真 stream,creative / delivery
late merge 是同一行重写。
| Stage | Callback 体形态 | Late update 怎么走 | 多 item? |
|---|---|---|---|
| preview_research | brief + samples · 单 slot 模型 | 填 slot 行,slot status flip | single |
| crawl_daily | items[] · 一次回多条 hot | 逐条 UPSERT 按 (source_slot_id, source_url),跨 run 防污染 |
true multi |
| creative | item · 单 creative_board | UPDATE 同 source_run_id 行,blocks/refs/display 整覆盖,meta 合并 |
single row |
| delivery | item · 单 delivery | 同 creative —— 同 source_run_id 行全覆盖 |
single row |
源码:src/lib/run-schemas.ts:169 是 CrawlDailyResultSchema.items[];
:197 是 CreativeResultSchema.item(单数);
:240 是 DeliveryResultSchema.item(单数)。
agent 自己发 /failed 和 tobatsu 平台发 /platform-terminal
可能同时发生。MB 不让两边争 status,只让先到者写 status + error。
status='failed' + error=<业务消息> 已经写好。
/platform-terminal 再到时检查 isPlatformErrorMessage(error),
发现是业务消息(不是平台消息)→ 把平台消息写进
error_secondary,主字段不动,返回 200。
status='failed' + error=<Tobatsu platform failure: ...>。
如果 agent 后续真的发了 /failed,由 /failed handler 决定如何融合。
重复 /platform-terminal → 检测到已经是平台消息 → 200 idempotent,丢弃。
源码:src/routes/tobatsu-platform.ts 完整 state machine。
MB 还保留特殊 case:status='done' 后到了 /platform-terminal,记 error_secondary 但不翻 status —— 业务成功覆盖平台兜底。
Mai 的 delivery agent 不稳定,需要分多次提交真正不同的产物(不是同一行重写)。
MB 的 crawl_daily 模型贴近这个需求,creative/delivery 不贴近。
所以照搬 MB /result 不对。
跟 tobatsu agent 行为对齐,避免 token 出现在 access log / URL。
禁 agent 在 meta 里塞 turn_id / source_run_id 等 server-controlled key。
UPSERT 的 update 段加 WHERE existing.source_run_id = EXCLUDED.source_run_id。
同 run_id 配错 task_id 必须 409,否则 dual-dispatch race 让 callback 写错 task。
保留首发的 timestamp,UI / 运维看时间不会被 late merge 错乱。
不接受任何 update / result,409 STALE_STATE。避免 agent 误以为还能 commit。
这次给 mai 出建议本来要 4 个 message,结果 6 个 —— 中间 mb-codex 看完我的方案后从源码角度反例了我,过程值得记下来当模板。
crawl_daily 当成 MB 通用模式。
建议 mai 走 "单 endpoint,任意次数 POST,server 自己判 late merge"
—— 把 MB /result 的形态直接套过去。
指出我读漏了 stage 分支。creative 跟 delivery handler
的 schema 是单数 item,late update 是 UPDATE 同 row,
根本不是新增条目。只有 crawl_daily 才有 items[] 真多 item。
重 grep src/lib/run-schemas.ts 169/197/240,确认
mb-codex 对。Mai 要的 "agent 不稳定 → 分多次提交真不同产物" 是 stream 语义,
MB creative/delivery 不是这种。改方案改成
delivery_update + delivery_result + platform_terminal。
"审 per-stage 分支重的代码,要把每个 case 都跑一遍 case 表, 不能假设 case 1 的模式适用其他。"