MB ↔ Tobatsu · Callback Contract · 2026-05-27

多次回传,
幂等收敛

agent 经常跑一半就崩。MB 不要求 agent 一次性出完所有产物 —— 你想发就发, 想发几次就发几次。最终是不是定稿,server 自己看 row 状态决定。

repo · material-board callback receiver · src/routes/runs.ts state machine · agent_runs.status reviewer · mb-cc-e4e0
§1OVERVIEW · 三条 callback 入口

一个 task,三个回传口。

MB 在 dispatch 时给 tobatsu 三个 URL:业务正常出料走 /result; agent 自己知道挂了走 /failed; runner 层挂掉 agent 没机会发,tobatsu platform 走兜底 /platform-terminal

POST /api/runs/:run_id/result

agent 出料就发。partial 也行,多次也行。

终态? 否 — server 端按 row 状态自动判首发 vs late merge

POST /api/runs/:run_id/failed

业务侧明确失败。agent 自己判断 fatal 时主动发。

终态? 是 — 但 /result 后续到达可翻回 done(recovery)

POST /api/tobatsu/platform-terminal

runner / skill / 平台层挂了,agent 没机会发 /failed。tobatsu 平台代发。

终态? 是 — 跟 /failed 走 race detection,记 error_secondary

callback URL 由 MB 在 dispatch 时 mint,写进 envelope: envelope.callback_urlenvelope.callback_failed_urlplatform_terminal_callback.url。auth 用 per-run 一次性 callback_token,存在 agent_runs.callback_token

§2STATE MACHINE · agent_runs.status

状态机里只有 5 个格子,但路径有 7 条。

你需要知道的所有"这次 callback 该怎么处理"答案,都在 run.status 这一个字段里。MB 看到当前状态 + 进来的 callback 类型,决定下一步。

running dispatch 后落库 done /result 收到,UPSERT failed /failed 或 platform-terminal cancelled 不接受后续 callback done · late update UPSERT 进同 row,status 不动 /result 首发 /failed 或 pt /result 后到 · recover 再次 /result · UPSERT 取消后 /result → 409 /platform-terminal · agent 没机会发 /failed 时兜底
正常路径 业务失败 拒绝 / 终态 平台兜底

源码:src/routes/runs.ts:768isLateUpdate = (run.status === 'done') — 就这一行 boolean 决定是首发 INSERT 还是 late UPSERT。

§3SEMANTICS · 多次 callback 的内核

首发 INSERT,后到 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 状态判断。

§4IDEMPOTENCY · 三层幂等防御

每一条 callback 都得过三关。

MB 不让 agent 自己生成 idempotency_key。靠三层自然约束兜底: run 层、task 层、item 层。

1

跟 run 绑死 — auth + 路由

URL path 里的 :run_id 必须等于 body 里的 run_id; Bearer header 里的 callback_token 必须等于 agent_runs.callback_token。三对一不齐 → 拒。

→ 不等:401 / 422,立刻断
2

跟 task 绑死 — 防 routing race

pinTaskIdAgainstStored:body 里的 task_id 必须等于 agent_runs.tobatsu_task_id(如果已经写过)。 首发时还没写就用 bindTaskIdIfMissing 把 task_id 钉进去, 防 dispatch 返回慢于 agent 第一次 callback。

→ 不等:409 ROUTING_MISMATCH
3

跟 item 绑死 — 自然键 UPSERT

不用客户端 UUID,用 row 的业务自然键。 hot_materials(source_slot_id, source_url)creative_boards / deliveriessource_run_id。 而且 UPSERT 的 update 段加 WHERE existing.source_run_id = EXCLUDED.source_run_id跨 run 写同一个 URL 时变 no-op dedup,retry-run B 改不到 run A 的 row。

→ 不等:silent dedup(200 OK,不污染他人数据)
§5PER-STAGE · 四个 stage 不是一样的

真"多 item" 只有一个 stage,其它都是单 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:169CrawlDailyResultSchema.items[]:197CreativeResultSchema.item(单数); :240DeliveryResultSchema.item(单数)。

§6FAILURE RACE · 两个 terminal 信号竞争时

谁先到谁赢,后到的写 error_secondary。

agent 自己发 /failed 和 tobatsu 平台发 /platform-terminal 可能同时发生。MB 不让两边争 status,只让先到者写 status + error。

CASE A · agent /failed 先到

业务消息赢,平台兜底降级

status='failed' + error=<业务消息> 已经写好。 /platform-terminal 再到时检查 isPlatformErrorMessage(error), 发现是业务消息(不是平台消息)→ 把平台消息写进 error_secondary,主字段不动,返回 200。

→ 200 race-recorded
CASE B · platform-terminal 先到

平台消息占位,等业务消息(可能永远不来)

status='failed' + error=<Tobatsu platform failure: ...>。 如果 agent 后续真的发了 /failed,由 /failed handler 决定如何融合。 重复 /platform-terminal → 检测到已经是平台消息 → 200 idempotent,丢弃。

→ 200 idempotent

源码:src/routes/tobatsu-platform.ts 完整 state machine。 MB 还保留特殊 case:status='done' 后到了 /platform-terminal,记 error_secondary 但不翻 status —— 业务成功覆盖平台兜底。

§7RECOMMENDATION · 给 Mai 用什么形态

不是单 endpoint,也不是 single+status,
是 update 多次 + result 一次。

Mai 的 delivery agent 不稳定,需要分多次提交真正不同的产物(不是同一行重写)。 MB 的 crawl_daily 模型贴近这个需求,creative/delivery 不贴近。 所以照搬 MB /result 不对。

FINAL SHAPE

三类 callback,明确语义边界。

POST /mai/.../delivery_update · 非终态 N 次 · 每次 commit 一个 partial artifact
POST /mai/.../delivery_result · 终态 1 次 · final snapshot,flip status
POST /mai/.../platform_terminal · race fallback only · 平台层挂时代发

5 个 trip wire — MB 踩过的

TW · 1

Bearer 在 header 不在 query

跟 tobatsu agent 行为对齐,避免 token 出现在 access log / URL。

TW · 2

stripReservedMeta 黑名单

禁 agent 在 meta 里塞 turn_id / source_run_id 等 server-controlled key。

TW · 3

跨 run 防污染

UPSERT 的 update 段加 WHERE existing.source_run_id = EXCLUDED.source_run_id

TW · 4

task_id pin

同 run_id 配错 task_id 必须 409,否则 dual-dispatch race 让 callback 写错 task。

TW · 5

late update 不刷 finished_at

保留首发的 timestamp,UI / 运维看时间不会被 late merge 错乱。

TW · 6

cancelled 状态拒绝 callback

不接受任何 update / result,409 STALE_STATE。避免 agent 误以为还能 commit。

§8POST-MORTEM · 我自己被纠错的那条对话

第一版建议不对,pair review 把我捞回来。

这次给 mai 出建议本来要 4 个 message,结果 6 个 —— 中间 mb-codex 看完我的方案后从源码角度反例了我,过程值得记下来当模板。

REVIEW DISCIPLINE · 自查

crawl_daily 当成 MB 通用模式。

我 · v1

建议 mai 走 "单 endpoint,任意次数 POST,server 自己判 late merge" —— 把 MB /result 的形态直接套过去。

mb-codex

指出我读漏了 stage 分支。creativedelivery 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 的模式适用其他。"