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);
+100
View File
@@ -0,0 +1,100 @@
import type { MessageReceiveEvent } from "./client.js";
export interface QueuedTrigger {
readonly projectId: string;
readonly chatId: string;
readonly prompt: string;
readonly msg: MessageReceiveEvent["message"];
readonly senderOpenId: string;
readonly actor: { readonly feishuOpenId: string; readonly chatId: string };
readonly enqueuedAt: number;
}
export interface TriggerQueueOptions {
readonly maxQueueSize?: number;
readonly maxWaitMs?: number;
}
const DEFAULT_MAX_QUEUE_SIZE = 5;
const DEFAULT_MAX_WAIT_MS = 300_000;
export class TriggerQueue {
readonly maxQueueSize: number;
readonly maxWaitMs: number;
private readonly queues = new Map<string, QueuedTrigger[]>();
constructor(options: TriggerQueueOptions = {}) {
this.maxQueueSize = Math.max(0, Math.trunc(options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
this.maxWaitMs = Math.max(0, Math.trunc(options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS));
}
enqueue(projectId: string, trigger: Omit<QueuedTrigger, "projectId" | "enqueuedAt">): number {
const queue = this.queues.get(projectId) ?? [];
if (queue.length >= this.maxQueueSize) return 0;
queue.push({
...trigger,
projectId,
enqueuedAt: Date.now(),
});
if (!this.queues.has(projectId)) {
this.queues.set(projectId, queue);
}
return queue.length;
}
dequeue(projectId: string): QueuedTrigger | null {
const queue = this.queues.get(projectId);
if (queue === undefined || queue.length === 0) return null;
const next = queue.shift() ?? null;
if (queue.length === 0) {
this.queues.delete(projectId);
}
return next;
}
peek(projectId: string): QueuedTrigger | null {
return this.queues.get(projectId)?.[0] ?? null;
}
length(projectId: string): number {
return this.queues.get(projectId)?.length ?? 0;
}
hasPending(projectId: string): boolean {
return this.length(projectId) > 0;
}
purgeExpired(): number {
const now = Date.now();
let removed = 0;
for (const [projectId, queue] of this.queues) {
const fresh = queue.filter((trigger) => !this.isExpired(trigger, now));
removed += queue.length - fresh.length;
if (fresh.length === 0) {
this.queues.delete(projectId);
} else if (fresh.length !== queue.length) {
this.queues.set(projectId, fresh);
}
}
return removed;
}
clear(projectId: string): number {
const count = this.length(projectId);
this.queues.delete(projectId);
return count;
}
clearAll(): void {
this.queues.clear();
}
isExpired(trigger: QueuedTrigger, now = Date.now()): boolean {
return trigger.enqueuedAt + this.maxWaitMs < now;
}
}
export const triggerQueue = new TriggerQueue();