This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/app/workers/task-advancer.ts
T
ChaiHaoandClaude Opus 5 94f31f267b 调优:并发推进的真根因 + loop 的三处行为修正
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
2026-07-27 11:31:41 +08:00

73 lines
2.6 KiB
TypeScript

/**
* 任务推进器 —— 「过程在后端」的落点(编码规范 §9;语义出处 OS 09)。
*
* 「环不依赖浏览器在线 —— 负责人关掉页面,活动照常推进」:
* 运行中的任务由本 worker 一拍一拍推,直到进入等待(等人 / 等事件)或收敛终态。
* 预算与停滞熔断在 `task.tick()` 里**环外先判**,这里不重复把关。
*/
import { and, eq, sql } from 'drizzle-orm';
import { taskTable } from '@modules/task';
import type { Container } from '../container.ts';
import { taskMutex } from './task-lock.ts';
export type AdvancerOptions = {
readonly container: Container;
/** 一轮最多推几个任务 */
readonly batchSize?: number;
/** 单个任务一轮最多推几拍 —— 防止一个任务占满 worker */
readonly maxTicks?: number;
};
export async function advanceOnce(options: AdvancerOptions): Promise<number> {
const { container } = options;
const rows = await container.db
.select({ id: taskTable.id })
.from(taskTable)
.where(and(eq(taskTable.runtime, 'running'), sql`${taskTable.terminal} is null`))
.limit(options.batchSize ?? 5);
let advanced = 0;
for (const row of rows) {
/*
* 与派发器共用同一把锁:事件投递也会推进同一个任务。
* 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。
*/
const ran = await taskMutex.tryRun(`task:${row.id}`, async () => {
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
try {
const outcome = await container.task.tick(row.id as never);
advanced += 1;
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
if (outcome.task.runtime !== 'running' || outcome.task.terminal !== undefined) break;
if (!outcome.progressed) break;
} catch (error) {
process.stderr.write(
`[task-advancer] ${row.id}:${error instanceof Error ? error.message : String(error)}\n`,
);
break;
}
}
return true;
});
void ran;
}
return advanced;
}
export function startTaskAdvancer(options: AdvancerOptions, intervalMs: number): { stop(): void } {
let stopped = false;
const timer = setInterval(() => {
if (stopped) return;
void advanceOnce(options).catch((error: unknown) => {
process.stderr.write(`[task-advancer] ${error instanceof Error ? error.message : String(error)}\n`);
});
}, intervalMs);
timer.unref();
return {
stop() {
stopped = true;
clearInterval(timer);
},
};
}