feat: add per-project message trigger wait queue

- TriggerQueue: FIFO per-project queue, max 5 items, 5min TTL expiry
- Replace reject-when-locked with queue: users get position feedback
- Run completion auto-starts next queued trigger (skips expired items)
- /reset clears project queue; 60s periodic purge interval
- Add unit tests (10) for queue lifecycle and edge cases
This commit is contained in:
2026-07-08 17:31:43 +08:00
parent 4479f88f4f
commit 36c0801e6f
5 changed files with 442 additions and 16 deletions
+104 -13
View File
@@ -37,9 +37,12 @@ import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js";
import { SenderNameCache } from "./senderCache.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
export { ApprovalManager } from "./approval.js";
export type { ApprovalResult, PendingApproval } from "./approval.js";
export { TriggerQueue, triggerQueue } from "./triggerQueue.js";
export type { QueuedTrigger, TriggerQueueOptions } from "./triggerQueue.js";
export const senderNameCache = new SenderNameCache();
interface TriggerDeps {
@@ -49,6 +52,7 @@ interface TriggerDeps {
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
readonly authorizer?: PermissionAuthorizer | undefined;
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined;
}
interface TriggerActor {
@@ -65,6 +69,8 @@ interface TriggerRunContext {
readonly actor: TriggerActor;
}
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
export interface TriggerHandler {
(event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>;
readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
@@ -80,6 +86,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager();
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
const batchContexts = new Map<string, TriggerRunContext>();
const messageBatcher = new MessageBatcher(async (mergedText, key) => {
if (key === undefined) {
@@ -100,15 +107,19 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}
}, deps.messageBatcherOptions);
async function startAgentRun(context: TriggerRunContext, cleanPrompt: string): Promise<void> {
async function startAgentRun(
context: TriggerRunContext,
cleanPrompt: string,
options: { readonly queueIfLocked?: boolean } = {},
): Promise<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
const queueIfLocked = options.queueIfLocked ?? true;
// ADR-0002: if locked by another run, reply busy.
// ADR-0002: if locked by another run, enqueue the trigger for this project.
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
await reactToMessage(rt, msg.message_id, "OnIt");
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
const project = await deps.prisma.project.findUnique({
@@ -117,7 +128,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
});
if (project === null) {
deps.logger.error({ projectId }, "feishu trigger: project missing");
return;
return "skipped";
}
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
@@ -148,7 +159,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
},
});
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return;
return "skipped";
}
const role = models.role(roleId);
const model = models.resolve(undefined, roleId);
@@ -234,9 +245,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
action: "run.lock_race",
metadata: { sender: await senderAuditMetadata(rt, senderOpenId) },
});
await reactToMessage(rt, msg.message_id, "OnIt");
await sendText(rt, chatId, "项目正在处理中,请稍候。");
return;
if (!queueIfLocked) return "locked";
return enqueueLockedTrigger(context, cleanPrompt);
}
const projectCtx: ProjectContext = {
@@ -344,7 +354,85 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
} catch (e) {
deps.logger.warn({ runId: run.id, err: e instanceof Error ? e.message : String(e) }, "trigger: could not release lock");
}
try {
await drainTriggerQueue(projectId, rt);
} catch (e) {
deps.logger.warn({ projectId, err: e instanceof Error ? e.message : String(e) }, "trigger: could not drain trigger queue");
}
});
return "started";
}
async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise<StartAgentRunOutcome> {
const position = triggerQueue.enqueue(context.projectId, {
chatId: context.chatId,
prompt: cleanPrompt,
msg: context.msg,
senderOpenId: context.senderOpenId,
actor: context.actor,
});
if (position === 0) {
await reactToMessage(context.rt, context.msg.message_id, "OnIt");
await sendText(context.rt, context.chatId, "队列已满,请稍后再试");
return "rejected";
}
await sendText(context.rt, context.chatId, `已加入队列(第${position}位),当前处理完成后将自动开始`);
return "queued";
}
async function drainTriggerQueue(projectId: string, rt: FeishuRuntime): Promise<void> {
while (triggerQueue.hasPending(projectId)) {
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) return;
const queued = triggerQueue.peek(projectId);
if (queued === null) return;
if (triggerQueue.isExpired(queued)) {
triggerQueue.dequeue(projectId);
deps.logger.info(
{ projectId, messageId: queued.msg.message_id, enqueuedAt: queued.enqueuedAt },
"feishu trigger: dropped expired queued trigger",
);
continue;
}
const next = triggerQueue.dequeue(projectId);
if (next === null) return;
const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { queueIfLocked: false });
if (outcome === "started") return;
if (outcome === "locked") {
requeueTrigger(next);
return;
}
}
}
function contextFromQueuedTrigger(trigger: QueuedTrigger, rt: FeishuRuntime): TriggerRunContext {
return {
msg: trigger.msg,
rt,
chatId: trigger.chatId,
projectId: trigger.projectId,
senderOpenId: trigger.senderOpenId,
actor: trigger.actor,
};
}
function requeueTrigger(trigger: QueuedTrigger): void {
const position = triggerQueue.enqueue(trigger.projectId, {
chatId: trigger.chatId,
prompt: trigger.prompt,
msg: trigger.msg,
senderOpenId: trigger.senderOpenId,
actor: trigger.actor,
});
if (position === 0) {
deps.logger.warn(
{ projectId: trigger.projectId, messageId: trigger.msg.message_id },
"feishu trigger: could not requeue locked queued trigger",
);
}
}
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
@@ -479,7 +567,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Slash commands: session management, not agent runs. These bypass the
// batcher. Session commands bypass the lock because they don't create runs;
// role/unknown slash prompts still run immediately through the normal lock.
// role/unknown slash prompts still use the normal run path and queue if locked.
if (cleanPrompt.startsWith("/")) {
const cmd = cleanPrompt.split(/\s+/)[0];
switch (cmd) {
@@ -513,6 +601,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
});
const cleared = triggerQueue.clear(projectId);
if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
}
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。");
return;
}
@@ -526,8 +618,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (msg.message_type === "text") {
const existing = await currentLockRunId(deps.prisma, projectId);
if (existing !== null) {
await reactToMessage(rt, msg.message_id, "OnIt");
await sendText(rt, chatId, "项目正在处理中,请稍候。");
await enqueueLockedTrigger(runContext, cleanPrompt);
return;
}
const key = messageBatchKey(chatId, senderOpenId);