异步之后的响应契约
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
副作用变异步后,响应不能撒谎:响应只承诺已经提交的事,还在路上的事给一个回执(事件 id / 任务 id),状态可查。需要“写完马上读到”的地方,用“在请求里等一小会儿,等不到就落回持久任务”。
每种副作用
| 副作用 | 响应里承诺什么 | 调用方怎么知道后续 |
|---|---|---|
| 搜索索引(写后马上搜) | corpus.create / corpus.update / corpus.promote 最多等 2 秒,返回 indexed: true;超时返回 indexed: false + index_job_id |
MCP / UI 凭任务 id 调 tasks.get;AI 看到 false 就知道稍后再搜 |
| 访客申请通知 | “已提交”,不说“已通知 owner” | owner 在任务面板看到邮件任务状态;失败最终 discarded 会告警 |
| 批准邮件 | 码(同步签发)+ 邮件任务;MCP access_requests.approve 最多等 2 秒 |
申请行显示邮件状态:发送中、已发送或失败(completion-hooks) |
| 预约通知 | 预约成功(预约行已提交),通知状态单列 | 同访客申请通知 |
| Obsidian 导入 | 导入的笔记数(已提交) | 逐篇的触发器事件给每篇建索引;任务面板里看得到这些任务 |
| 微站构建 | 已排队 + 构建 id | 等待者被 microsite.build.settled(按 owner 分键的 NOTIFY)唤醒,不再靠进程内信号 |
jobs.fetch_new |
所有源 20 秒内抓完就返回职位列表;否则返回 {job_ids, pending: true} |
jobs.fetch_result {job_ids} 返回同一形状 |
| webhook | 对 owner 的写入响应里不承诺任何投递 | 投递日志 |
最多等 2 秒,等不到给回执
sequenceDiagram
participant AI as owner's AI (MCP)
participant UC as corpus.create
participant DB as Postgres
participant Q as Queue
participant IX as corpus.index
AI->>UC: create wiki
UC->>DB: write + event (same transaction) and commit
UC->>Q: LatestFor → AwaitFanout → Wait, up to 2 s in all
alt done within 2 s (common)
Q->>IX: Handle
IX-->>Q: ok
Q-->>UC: completed
UC-->>AI: id + indexed: true
else timeout (index slow or retrying)
UC-->>AI: id + indexed: false + index_job_id
Note over Q,IX: the job keeps running, and retries with backoff on failure
end
等待分三步:找到这篇笔记最新的 corpus.note.changed 事件(LatestFor);等 relay 把它扇出(AwaitFanout,由 NOTIFY standmeet_events_fanned 唤醒);再等 corpus.index 任务(Wait,由 NOTIFY standmeet_job_final 唤醒)。每一步都靠每个进程一条共享的 LISTEN 连接,不是每个请求各开一个连接轮询。每个频道最多 64 个等待者,超了就直接返回 indexed: false,不排队(concurrency-control)。完结信号是一条 pg_notify,不是 outbox 事件(completion-hooks)。
常见情况下,回执的耗时是 index 队列的一次轮询(≤ 100 毫秒,concurrency-control)加 River 批量完结器的周期:它每 250 毫秒记一次完结。
文案规则
这条规则也管文案:UI 和 MCP 的文字只说已确认的事,“已发送”只在任务 completed 后出现。