forked from EduCraft/curriculum-project-hub
313e890b6c
群消息事件 → 创建 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。
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|