forked from EduCraft/curriculum-project-hub
feat(hub): 飞书 @bot 触发链路 + ProjectAgentLock 不变式(ADR-0001/0002)
群消息事件 → 创建 AgentRun → 获锁 → 调 runAgent → 释放锁 + 状态回写: - feishu/client.ts: lark WSClient 长连接接收 im.message.receive_v1; sendText 经 client.im.v1 发送(SDK 运行时动态 API,类型弱化务实收口) - feishu/trigger.ts: @bot 识别(chat 绑定解析 ADR-0001)→ 创建 run+ session(provider-bound ADR-0017)→ acquireLock(ADR-0002,竞态回退 busy)→ runAgent(fire-and-forget)→ finally releaseLock;非项目群/无 @bot/非文本 全部忽略 - lock.ts: ADR-0002 WellFormed 落代码——currentLockRunId 读锁时校验 持锁 run 非终止,终止 run 持锁即 release-path bug(抛而非静默) - models.ts: resolve 提到 ModelRegistry 接口(之前漏在实现上) - server.ts: Fastify + lark ws 启动,/api/healthz prompt 提取 smoke 通过(@bot+prompt→prompt;纯@bot→null;无mention→null; 非文本→null)。修了 mention 正则 bug(/@_\w+_/ 的回溯错误)。tsc rc=0。
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* ProjectAgentLock — ADR-0002 invariant enforcement at the DB layer.
|
||||
*
|
||||
* `projectId @id` gives at-most-one lock per project; `runId @unique` gives a
|
||||
* run holds at most one lock. Those are DB-level. The spec's `WellFormed`
|
||||
* invariant ("holder is a non-terminal run") is app-level: it must hold on
|
||||
* every read of the lock table. `assertLockHolderActive` enforces it on the
|
||||
* read path — if a lock exists but its run is terminal, that's a bug (the run
|
||||
* should have released it), surfaced as an error rather than silently trusted.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { AgentRunStatus } from "@prisma/client";
|
||||
|
||||
/// spec RunState.Terminal: completed/failed/timedOut/canceled.
|
||||
/// active/waitingForUser are NOT terminal — the lock must still be held.
|
||||
const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
"CANCELED",
|
||||
]);
|
||||
|
||||
export function isTerminal(status: AgentRunStatus): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the project lock for a run. Throws if the project is already locked
|
||||
* by another run (ADR-0002 exclusivity). The unique runId constraint means a
|
||||
* run that already holds a lock elsewhere will also fail — that's correct, a
|
||||
* run scopes to one project.
|
||||
*/
|
||||
export async function acquireLock(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
runId: string,
|
||||
holderUserId: string | null,
|
||||
): Promise<void> {
|
||||
await prisma.projectAgentLock.create({
|
||||
data: { projectId, runId, holderUserId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock owned by a run. Idempotent — a no-op if no lock exists.
|
||||
* Called on run completion/failure/timeout/cancellation (ADR-0002).
|
||||
*/
|
||||
export async function releaseLock(prisma: PrismaClient, runId: string): Promise<void> {
|
||||
await prisma.projectAgentLock.deleteMany({ where: { runId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The spec's `LockTable.WellFormed` on a single read: if `projectId` has a
|
||||
* lock owned by `runId`, that run must be non-terminal. Throws if a terminal
|
||||
* run still holds a lock — that's a release-path bug, not a recoverable state.
|
||||
*
|
||||
* Returns the lock's runId if a healthy lock exists, or null if unlocked.
|
||||
*/
|
||||
export async function currentLockRunId(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
): Promise<string | null> {
|
||||
const lock = await prisma.projectAgentLock.findUnique({
|
||||
where: { projectId },
|
||||
select: { runId: true },
|
||||
});
|
||||
if (lock === null) return null;
|
||||
|
||||
const run = await prisma.agentRun.findUnique({
|
||||
where: { id: lock.runId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (run !== null && isTerminal(run.status)) {
|
||||
throw new Error(
|
||||
`lock invariant violated: project ${projectId} locked by terminal run ${lock.runId} (status ${run.status}) — release path bug (ADR-0002 WellFormed)`,
|
||||
);
|
||||
}
|
||||
return lock.runId;
|
||||
}
|
||||
Reference in New Issue
Block a user