forked from bai/curriculum-project-hub
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:
+104
-13
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user