E2E 跑 loop 时 A3 一口气发了八张一模一样的「请处理」卡。逐层查下去,四处: ① **并发推进的真根因**:`pg_try_advisory_lock` 是**会话级**的,而我们经连接池取连接 —— 加锁与解锁多半落在不同连接上,解锁解不掉自己那把;同一连接被复用时又因可重入而直接放行。 两头都不对,于是同一任务被并发推进(现象:同一步跑两遍、同一张卡发两张、 同一行草稿写出两条互相打架的版本)。前一轮「共用同一把锁」的修法方向对、器械错。 改用**进程内按目标互斥**(MVP 单进程,确定且够用),推进器与派发器共用一把; 多进程部署时再叠事务级 `pg_try_advisory_xact_lock`(随事务释放,不依赖连接归属)。 实测:同一单从 8 张卡降到 1 张,2 回合后落到等待。 ② **非法行动会把回合打死**:AI 提了不合法的生命周期迁移(Available → Disabled), `applyAction` 抛出、运行时没落笔、推进器一遍遍重来。引擎拒绝它是对的 ——「语义忠实:引擎不得私改迁移拓扑」——但不该因此崩掉。 改为:拒绝 + 留痕 + 把理由喂回下一回合的上下文。AI 提出非法行动是预期内的事。 ③ **副作用抢在校验之前**:卡在迁移合法性判定**之前**就发出去了,于是每个被拒的回合 都白发一张卡 —— 卡收不回,负责人工作台上堆起一摞没人知道怎么答的「请处理」。 human / tool 两个分支改为**先验迁移再动手**。 ④ **裸调用**:模型只回一个「请处理」。按「调用必附上下文,裸调用不得发出」(AM 06) 把卡面结构写进出参 schema(title / ask / basis / fields 必填),提示里讲明要求, 归一时用当下局面兜底;卡片装配也修了 —— 结构化 basis 优先,嵌套对象转 JSON, 不再出现一行 `[object Object]`。 实测卡面:「请确认 NE555P 历史报价并判定采用策略」+ 三个回填字段。 gates: tsc / eslint / depcruise(336 模块)/ api-report / 429 测试 全绿 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
/**
|
|
* 投递派发器 —— ADR-008 #2。
|
|
*
|
|
* 按 `state='pending' and next_attempt_at <= now()` 取投递记录,
|
|
* 对**目标 ID 取 advisory lock** 串行执行 —— 这正是「同任务按序」的实现(§7.1)。
|
|
* 消费幂等由 `sys.event_consumption(event_id, consumer)` 唯一约束承担。
|
|
*
|
|
* 消费者(任务 / 触发器)随 M4 / M5 落地;在此之前派发器把投递标记为已送达并留痕,
|
|
* 保证投递链路本身可测、不积压(docs/debt.md · M1-3)。
|
|
*/
|
|
import { sql } from 'drizzle-orm';
|
|
import type { Delivery } from '@modules/event';
|
|
import { claimPendingDeliveries } from '@modules/event';
|
|
import type { Container } from '../container.ts';
|
|
import { taskMutex } from './task-lock.ts';
|
|
|
|
/** 消费者:按目标类型分派 */
|
|
export type Consumer = (delivery: Delivery, container: Container) => Promise<void>;
|
|
|
|
export type DispatcherDeps = {
|
|
readonly container: Container;
|
|
readonly consumers: ReadonlyMap<Delivery['target']['kind'], Consumer>;
|
|
readonly batchSize?: number;
|
|
};
|
|
|
|
export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
|
const { container } = deps;
|
|
const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20);
|
|
let handled = 0;
|
|
|
|
for (const delivery of batch) {
|
|
// 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮
|
|
await taskMutex.tryRun(`${delivery.target.kind}:${String(delivery.target.id)}`, async () => {
|
|
try {
|
|
const consumer = deps.consumers.get(delivery.target.kind);
|
|
if (consumer !== undefined) {
|
|
const claimed = await container.event.claimConsumption(
|
|
delivery.eventId,
|
|
`${delivery.target.kind}:${delivery.target.id}`,
|
|
);
|
|
// 至少一次 + 幂等:已消费过的重复投递直接标记完成,不再执行副作用
|
|
if (claimed) await consumer(delivery, container);
|
|
}
|
|
await container.db.execute(
|
|
sql`update sys.event_delivery set state = 'delivered' where id = ${delivery.id}`,
|
|
);
|
|
// 已落库、已送达之后才广播 —— 广播不是投递,只是告诉在线浏览器「这个目标动了」
|
|
container.broadcaster.publish({
|
|
kind: delivery.target.kind === 'message-position' ? 'staff' : 'task',
|
|
id: String(delivery.target.id),
|
|
reason: delivery.target.kind,
|
|
});
|
|
handled += 1;
|
|
} catch (error) {
|
|
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
|
|
}
|
|
});
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
export function startDispatcher(deps: DispatcherDeps, intervalMs: number): { stop(): void } {
|
|
let stopped = false;
|
|
const tick = async (): Promise<void> => {
|
|
if (stopped) return;
|
|
try {
|
|
await dispatchOnce(deps);
|
|
} catch (error) {
|
|
process.stderr.write(`[dispatcher] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
};
|
|
const timer = setInterval(() => void tick(), intervalMs);
|
|
timer.unref();
|
|
return {
|
|
stop() {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
}
|