evOo-local/apps/api/src/thread-target.ts
Cursor Agent 57f3ee4958 fix(api): route group follow-ups to the waiting member bot
Group threads/followUp always steered to members[0], so a follow-up
while a later member had waiting_input went to the wrong bot or started
a fresh run on the first member instead of continuing the ask.

Resolve the target bot like group sends: prefer waiting ask/takeover on
any member, then the newest steerable active run, then @mentions, then
the default first member. Extract followUpGroupThreadMessage and add
regression tests.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 07:04:42 +00:00

1261 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ORPCError } from "@orpc/server";
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import {
type Actor,
GROUP_MEMBER_MIN,
type GroupMember,
type MessageBlock,
type MessageReaction,
REPLY_QUOTE_MAX_LENGTH,
type RunStatus,
type ThreadSnapshot,
} from "@rakazo/contracts";
import {
ACTIVE_RUN_STATUSES,
blocksToAgentHistoryText,
isActive,
projectMessages,
resolveGroupTargetBotIds,
runFailureError,
} from "@rakazo/core";
import {
appendEventInTransaction,
createGroupRepos,
createRepos,
createThreadMessageInTransaction,
expireComputerExecutionLeases,
IsolationError,
lockOwnedGroup,
type Prisma,
type PrismaClient,
type ThreadEvents,
touchGroupUpdatedAt,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import {
buildSendPrompt,
buildUserMessageBlocks,
resolveGroupSendAttachments,
resolveSendAttachments,
} from "./artifacts.js";
import { resolveBusyBotName, toComputerStatus } from "./computer-status.js";
import { withSerializableRetry } from "./serializable-retry.js";
import { loadMessagePage } from "./thread-message-pages.js";
export type ThreadTarget =
| {
kind: "bot";
botId: string;
threadId: string;
bot: Awaited<ReturnType<ReturnType<typeof createRepos>["getBot"]>>;
}
| {
kind: "group";
groupId: string;
threadId: string;
groupName: string;
members: GroupMember[];
memberBotIds: string[];
};
/**
* Flatten text for excerpt comparison. The parent blocks hold markdown
* source while the selection captures rendered text, so structural syntax
* is normalized away: table delimiters and alignment rows, list markers,
* heading and blockquote markers, link targets, emphasis characters.
* Semantic punctuation (: + - . ! #) stays on both sides — otherwise
* "C++ is fast" would accept a fabricated "C is fast".
*/
function flattenForQuoteMatch(text: string, markdownSource = false): string {
let fence: { marker: string; quoteDepth: number } | undefined;
return text
.split("\n")
.filter((line) => !/^\s*\|?[\s:|-]+\|?\s*$/.test(line))
.map((line) => {
const normalized = line
.replace(/^\s*(?:>\s*)+/, "")
.replace(/^\s*#{1,6}\s+/, "")
.replace(/^\s*[-*+•]\s+/, "");
if (!markdownSource) return normalized;
if (fence) {
let boundary = line;
const quoteDepth = fence.quoteDepth;
for (let depth = 0; depth < quoteDepth; depth++) {
const prefix = /^ {0,3}>[ \t]?/.exec(boundary)?.[0];
if (!prefix) {
fence = undefined;
break;
}
boundary = boundary.slice(prefix.length);
}
if (fence) {
const closing = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(boundary)?.[1];
if (closing && closing[0] === fence.marker[0] && closing.length >= fence.marker.length) {
fence = undefined;
}
return normalized;
}
}
let source = line;
let quoteDepth = 0;
for (
let prefix = /^ {0,3}>[ \t]?/.exec(source)?.[0];
prefix;
prefix = /^ {0,3}>[ \t]?/.exec(source)?.[0]
) {
source = source.slice(prefix.length);
quoteDepth++;
}
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(source);
const marker = match?.[1];
const suffix = match?.[2] ?? "";
if (marker && (marker[0] !== "`" || !suffix.includes("`"))) {
fence = { marker, quoteDepth };
return normalized;
}
// Test source syntax before stripping headings or other visible containers.
return /^ {0,3}\d{1,9}[.)][ \t]+/.test(source)
? normalized.replace(/^ {0,3}\d{1,9}[.)][ \t]+/, "")
: normalized;
})
.join(" ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.toLowerCase()
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#0?39;|&apos;/g, "'")
.replace(/[‘’]/g, "'")
.replace(/[“”]/g, '"')
.replace(/[\\`*_~|[\]()•]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
const excerpt = flattenForQuoteMatch(quote);
if (!excerpt) return false;
const parent = blocksToAgentHistoryText(blocks);
// Keep existing matches (including visible numbering in code) before removing list syntax.
return (
flattenForQuoteMatch(parent).includes(excerpt) ||
flattenForQuoteMatch(parent, true).includes(excerpt)
);
}
const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
function splitMentionTargets(mentions: MentionTargetInput[] | undefined) {
const botMentionIds = new Set<string>();
const groupMentionIds = new Set<string>();
const routineMentionIds = new Set<string>();
const connectorMentionIds = new Set<string>();
for (const mention of mentions ?? []) {
if (typeof mention === "string") {
botMentionIds.add(mention);
continue;
}
if (mention.kind === "bot") botMentionIds.add(mention.id);
if (mention.kind === "group") groupMentionIds.add(mention.id);
if (mention.kind === "routine") routineMentionIds.add(mention.id);
if (mention.kind === "connector") connectorMentionIds.add(mention.id);
}
return {
botMentionIds: [...botMentionIds],
groupMentionIds: [...groupMentionIds],
routineMentionIds: [...routineMentionIds],
connectorMentionIds: [...connectorMentionIds],
};
}
async function resolveOwnedConnectorDisplayNames(
tx: Prisma.TransactionClient,
actor: Actor,
connectionIds: string[],
) {
if (!connectionIds.length) return [];
const rows = await tx.connection.findMany({
where: {
id: { in: connectionIds },
spaceId: actor.spaceId,
userId: actor.userId,
status: "connected",
},
select: { id: true, displayName: true },
});
if (rows.length !== connectionIds.length) throw new IsolationError();
const byId = new Map(rows.map((row) => [row.id, row.displayName]));
return connectionIds.map((id) => byId.get(id) ?? "connector");
}
function sendRunClientNonce(
clientNonce: string | undefined,
messageId: string,
botId?: string,
): string | undefined {
if (!clientNonce) return undefined;
return botId ? `send:${messageId}:${botId}` : `send:${messageId}`;
}
async function enqueueRunsNeedingContinue(
jobs: JobPublisher,
runs: Array<{ id: string; status: string }>,
) {
await Promise.all(
runs
.filter((run) => RUNS_NEEDING_CONTINUE.has(run.status))
.map((run) =>
jobs.enqueue(runContinueJob(run.id)).catch((error) => {
// The queued run is durable; the reconciler repairs a missed immediate wake.
getLogger().error("thread send enqueue", error);
}),
),
);
}
async function findSendReceipt(prisma: PrismaClient, threadId: string, clientNonce: string) {
return prisma.message.findUnique({
where: { threadId_clientNonce: { threadId, clientNonce } },
include: { sourceRuns: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
});
}
async function replayExistingSend(
deps: { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher },
threadId: string,
clientNonce: string | undefined,
) {
if (!clientNonce) return null;
const message = await findSendReceipt(deps.prisma, threadId, clientNonce);
if (!message) return null;
const receiptEvent = await deps.prisma.event.findFirst({
where: {
threadId,
type: "thread.message.created",
payload: { path: ["messageId"], equals: message.id },
},
orderBy: { seq: "desc" },
select: { payload: true },
});
const receiptRunIds = sendEventRunIds(receiptEvent?.payload);
const receiptRuns = receiptRunIds.length
? await deps.prisma.run.findMany({ where: { id: { in: receiptRunIds } } })
: [];
const receiptRunById = new Map(receiptRuns.map((run) => [run.id, run]));
const orderedReceiptRuns = receiptRunIds.flatMap((id) => {
const run = receiptRunById.get(id);
return run ? [run] : [];
});
const linkedRun =
message.sourceRuns[0] ??
(message.runId ? await deps.prisma.run.findUnique({ where: { id: message.runId } }) : null);
if (!linkedRun && orderedReceiptRuns.length === 0) return null;
const runs = orderedReceiptRuns.length
? orderedReceiptRuns
: message.sourceRuns.length
? message.sourceRuns
: [linkedRun!];
await enqueueRunsNeedingContinue(deps.jobs, runs);
const latestEvent = await deps.prisma.event.findFirst({
where: { threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
if (latestEvent) {
await deps.events.notify(threadId, latestEvent.seq).catch((error) => {
// Subscribers catch up from the durable event cursor after a missed realtime wake.
getLogger().error("thread send realtime notification", error);
});
}
return sendResult(message, runs);
}
function sendEventRunIds(payload: Prisma.JsonValue | undefined): string[] {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return [];
const runIds = (payload as { runIds?: unknown }).runIds;
return Array.isArray(runIds) ? runIds.filter((id): id is string => typeof id === "string") : [];
}
function sendResult(message: { seq: number }, runs: Array<{ id: string; taskId: string }>) {
const first = runs[0];
if (!first) throw new IsolationError("Send did not create a run");
return {
taskId: first.taskId,
runId: first.id,
seq: message.seq,
runIds: runs.map((run) => run.id),
};
}
export async function cancelSupersededQueuedRuns(
tx: Prisma.TransactionClient,
input: { threadId: string; botIds: string[]; keepRunIds: string[] },
) {
const superseded = await tx.run.findMany({
where: {
threadId: input.threadId,
botId: { in: input.botIds },
status: "queued",
OR: [{ trigger: "user", sourceMessage: { role: "user" } }, { trigger: "reaction" }],
id: { notIn: input.keepRunIds },
},
select: { id: true, taskId: true },
});
if (superseded.length === 0) return;
const now = new Date();
await tx.run.updateMany({
where: { id: { in: superseded.map((run) => run.id) } },
data: { status: "cancelled", completedAt: now },
});
await tx.task.updateMany({
where: { id: { in: superseded.map((run) => run.taskId) } },
data: { status: "cancelled" },
});
}
async function lockAndLoadGroupMembers(
tx: Prisma.TransactionClient,
actor: Actor,
target: Extract<ThreadTarget, { kind: "group" }>,
) {
await lockOwnedGroup(tx, actor, target.groupId);
const group = await tx.chatGroup.findFirst({
where: {
id: target.groupId,
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
thread: { id: target.threadId },
},
include: {
members: {
where: { bot: { archivedAt: null } },
include: { bot: { select: { id: true, name: true, color: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!group || group.members.length < GROUP_MEMBER_MIN) throw new IsolationError();
return group.members.map((member) => ({
botId: member.bot.id,
name: member.bot.name,
color: member.bot.color,
}));
}
export async function resolveThreadTarget(
prisma: PrismaClient,
actor: Actor,
input: { botId?: string; groupId?: string },
): Promise<ThreadTarget> {
const repos = createRepos(prisma);
const groupRepos = createGroupRepos(prisma);
if (input.botId) {
const bot = await repos.getBot(actor, input.botId);
if (!bot.thread) throw new IsolationError();
return {
kind: "bot",
botId: bot.id,
threadId: bot.thread.id,
bot,
};
}
if (input.groupId) {
const group = await groupRepos.getGroupTarget(actor, input.groupId);
if (!group.thread) throw new IsolationError();
const members = group.members.map((member) => ({
botId: member.bot.id,
name: member.bot.name,
color: member.bot.color,
status: member.bot.runs[0]?.status ?? "idle",
}));
return {
kind: "group",
groupId: group.id,
threadId: group.thread.id,
groupName: group.name,
members,
memberBotIds: members.map((member) => member.botId),
};
}
throw new IsolationError();
}
export async function threadHead(prisma: PrismaClient, target: ThreadTarget) {
const latest = await prisma.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return { threadId: target.threadId, cursor: latest?.seq ?? -1 };
}
export async function threadSnapshot(
deps: { prisma: PrismaClient },
target: ThreadTarget,
): Promise<ThreadSnapshot> {
// Lock the thread row so messages, the event cursor, active runs, and live
// progress are read from one consistent commit. A torn Promise.all can
// otherwise advance the client cursor past thread.message.created while the
// ask message page still omits it — leaving waiting_input with no AskCard.
if (target.kind === "bot") {
const [busyBotName, core] = await Promise.all([
resolveBusyBotName(deps.prisma, {
computerId: target.bot.computer?.id,
botId: target.botId,
botName: target.bot.name,
}),
deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR SHARE`;
const [messagePage, last, waitingRun, busyOrFailed] = await Promise.all([
loadMessagePage(tx, target.threadId, undefined, THREAD_MESSAGE_PAGE_SIZE),
tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
}),
// Waiting asks win over a concurrent busy run (including peer bot_message).
tx.run.findFirst({
where: {
botId: target.botId,
threadId: target.threadId,
status: { in: ["waiting_input", "waiting_takeover"] },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}),
tx.run.findFirst({
where: {
botId: target.botId,
threadId: target.threadId,
// Hide peer bot_message busy/failed noise; waiting is handled above.
trigger: { not: "bot_message" },
status: { in: [...ACTIVE_RUN_STATUSES, "failed"] },
},
// The id tiebreak keeps ordering deterministic under equal
// timestamps, matching the supersession probe below.
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}),
]);
const run = waitingRun ?? busyOrFailed;
// A failed run is only the thread's word while it is still the newest
// terminal run; otherwise a stale failure would resurface in the
// composer error strip on every load, forever. Instead of comparing
// timestamps (equal createdAt values reverse under gt/gte), ask for
// the newest terminal run under the same deterministic ordering and
// check whether it is this failure.
const newestTerminal =
run?.status === "failed"
? await tx.run.findFirst({
where: {
botId: target.botId,
threadId: target.threadId,
// Peer bot_message failures must not bury a user-visible failure.
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
select: { id: true },
})
: null;
const currentRun = run?.status === "failed" && newestTerminal?.id !== run.id ? null : run;
const liveEvents =
currentRun && isActive(currentRun.status as RunStatus)
? await tx.event.findMany({
where: {
threadId: target.threadId,
runId: currentRun.id,
type: {
in: [
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
},
},
orderBy: { seq: "asc" },
})
: [];
return { messagePage, last, run: currentRun, liveEvents };
}),
]);
return {
botId: target.botId,
threadId: target.threadId,
cursor: core.last?.seq ?? -1,
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
olderCursor: core.messagePage.olderCursor,
run: core.run ? mapRun(core.run) : null,
computer: toComputerStatus(target.botId, target.bot.computer, busyBotName),
};
}
const core = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR SHARE`;
const [messagePage, last, activeRuns, recentTerminals] = await Promise.all([
loadMessagePage(tx, target.threadId, undefined, THREAD_MESSAGE_PAGE_SIZE),
tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
}),
tx.run.findMany({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
// Include waiting peer runs so their ask cards stay answerable.
OR: [
{ trigger: { not: "bot_message" } },
{ status: { in: ["waiting_input", "waiting_takeover"] } },
],
},
orderBy: { createdAt: "desc" },
}),
// Recently updated terminals (completion bumps updatedAt). pickLatestTerminalRun then
// ranks by completedAt ?? createdAt so null timestamps cannot revive a stale failure.
tx.run.findMany({
where: {
threadId: target.threadId,
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 50,
}),
]);
const liveEvents =
activeRuns.length > 0
? await tx.event.findMany({
where: {
threadId: target.threadId,
runId: { in: activeRuns.map((run) => run.id) },
type: {
in: [
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
},
},
orderBy: { seq: "asc" },
})
: [];
return {
messagePage,
last,
activeRuns,
terminalRun: pickLatestTerminalRun(recentTerminals),
liveEvents,
};
});
const primaryActiveRun = pickPrimaryActiveRun(core.activeRuns);
return {
groupId: target.groupId,
groupName: target.groupName,
members: target.members,
threadId: target.threadId,
cursor: core.last?.seq ?? -1,
messages: messagesWithLiveEvents(core.messagePage.messages, core.liveEvents),
olderCursor: core.messagePage.olderCursor,
// Match the live reducer: a failed latest terminal stays in run even while siblings are
// still active or start late. A newer completed/cancelled terminal clears it.
run:
core.terminalRun?.status === "failed"
? mapRun(core.terminalRun)
: primaryActiveRun
? mapRun(primaryActiveRun)
: null,
activeRuns: core.activeRuns.map(mapRun),
};
}
/**
* Prefer a waiting ask/takeover over a merely-busy run for the group's headline
* `run` field, even when the busy run started more recently — createdAt-desc
* ordering alone would let a fresh busy run for one bot bury an older waiting
* card for another. `activeRuns` still carries every active run regardless of
* which one is picked here, so nothing is hidden from the client, only the
* single-run summary field.
*/
function pickPrimaryActiveRun<T extends { status: string }>(runs: readonly T[]): T | undefined {
return (
runs.find((run) => run.status === "waiting_input" || run.status === "waiting_takeover") ??
runs[0]
);
}
/** Latest terminal by end time (completedAt, else createdAt), then createdAt, then id. */
function pickLatestTerminalRun<T extends { id: string; createdAt: Date; completedAt: Date | null }>(
runs: T[],
): T | null {
if (runs.length === 0) return null;
return runs.reduce((best, run) => {
const bestEnd = (best.completedAt ?? best.createdAt).getTime();
const runEnd = (run.completedAt ?? run.createdAt).getTime();
if (runEnd !== bestEnd) return runEnd > bestEnd ? run : best;
if (run.createdAt.getTime() !== best.createdAt.getTime()) {
return run.createdAt > best.createdAt ? run : best;
}
return run.id > best.id ? run : best;
});
}
function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0],
) {
const live = projectMessages(liveEvents).filter((message) => {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
return true;
}
if (!message.id.startsWith("subagent:")) return false;
return !persisted.some((row) =>
row.blocks.some(
(block) => block.kind === "subagent" && message.id === `subagent:${block.agentId}`,
),
);
});
return [...persisted, ...live];
}
function mapRun(run: {
id: string;
botId: string;
threadId: string;
taskId: string;
status: string;
trigger: string;
routineId: string | null;
modelProvider: string | null;
modelId: string | null;
error: string | null;
startedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
}) {
return {
id: run.id,
botId: run.botId,
threadId: run.threadId,
taskId: run.taskId,
status: run.status as never,
trigger: run.trigger as never,
routineId: run.routineId ?? null,
modelProvider: run.modelProvider,
modelId: run.modelId,
// Same display clamp as live run.failed events so a huge stored error cannot bypass it.
error:
run.status === "failed"
? runFailureError({ type: "run.failed", payload: { error: run.error } })
: run.error,
startedAt: run.startedAt?.toISOString() ?? null,
completedAt: run.completedAt?.toISOString() ?? null,
createdAt: run.createdAt.toISOString(),
};
}
export async function sendThreadMessage(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
input: {
text?: string;
artifactIds?: string[];
mentions?: MentionTargetInput[];
replyToMessageId?: string;
replyQuote?: string;
clientNonce?: string;
},
) {
const existing = await replayExistingSend(deps, target.threadId, input.clientNonce);
if (existing) return existing;
// The excerpt is rendered text while blocks hold markdown source, so it
// can't be substring-verified verbatim — enforce the cap here, then check
// the flattened form against the parent inside the transaction.
let replyQuote = input.replyQuote?.trim().slice(0, REPLY_QUOTE_MAX_LENGTH) || undefined;
if (replyQuote && !input.replyToMessageId) {
throw new ORPCError("BAD_REQUEST", { message: "replyQuote requires replyToMessageId." });
}
const commit = () =>
deps.prisma.$transaction(async (tx) => {
if (input.replyToMessageId) {
const reply = await tx.message.findFirst({
where: { id: input.replyToMessageId, threadId: target.threadId },
select: { id: true, blocks: true },
});
if (!reply) throw new IsolationError();
// Client-supplied excerpts are untrusted: drop a mismatch instead of
// failing the send — the reply still lands, just without the quote.
if (
replyQuote &&
!quoteAppearsInBlocks(
replyQuote,
Array.isArray(reply.blocks) ? (reply.blocks as MessageBlock[]) : [],
)
) {
replyQuote = undefined;
}
}
if (target.kind === "bot") {
const mentionTargets = splitMentionTargets(input.mentions);
const { blocks: attachmentBlocks, artifacts } = await resolveSendAttachments(
{ prisma: tx },
actor,
target.botId,
input.artifactIds,
);
const connectorNames = await resolveOwnedConnectorDisplayNames(
tx,
actor,
mentionTargets.connectorMentionIds,
);
const blocks = buildUserMessageBlocks(input.text, attachmentBlocks);
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "user",
blocks,
replyToMessageId: input.replyToMessageId,
replyQuote,
clientNonce: input.clientNonce,
});
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: target.botId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, taskId: true, status: true },
});
if (activeRuns.some((run) => !STEERABLE_RUN_STATUSES.has(run.status))) {
throw new ORPCError("CONFLICT", {
message: "Answer the pending ask first.",
});
}
const active = activeRuns[0];
if (active) {
await tx.steeringMessage.create({
data: {
messageId: message.id,
botId: target.botId,
userId: actor.userId,
runId: active.id,
},
});
await tx.message.update({ where: { id: message.id }, data: { runId: active.id } });
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
runId: active.id,
payload: {
messageId: message.id,
role: "user",
blocks,
replyToMessageId: input.replyToMessageId,
replyQuote,
},
});
return { message, runs: [active], eventSeq: event.seq };
}
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId: target.botId,
threadId: target.threadId,
userId: actor.userId,
prompt: buildSendPrompt(input.text, artifacts, connectorNames),
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId: target.botId,
threadId: target.threadId,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "user",
clientNonce: sendRunClientNonce(input.clientNonce, message.id),
sourceMessageId: message.id,
},
});
await tx.message.update({ where: { id: message.id }, data: { runId: run.id } });
await cancelSupersededQueuedRuns(tx, {
threadId: target.threadId,
botIds: [target.botId],
keepRunIds: [run.id],
});
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
runId: run.id,
payload: {
messageId: message.id,
role: "user",
blocks,
runIds: [run.id],
replyToMessageId: input.replyToMessageId,
replyQuote,
},
});
return { message, runs: [run], eventSeq: event.seq };
}
const members = await lockAndLoadGroupMembers(tx, actor, target);
const memberBotIds = members.map((member) => member.botId);
const mentionTargets = splitMentionTargets(input.mentions);
const targetBotIds = resolveGroupTargetBotIds({
text: input.text ?? "",
members: members.map((member) => ({ id: member.botId, name: member.name })),
explicitMentions: mentionTargets.botMentionIds,
});
const { blocks: attachmentBlocks, artifacts } = await resolveGroupSendAttachments(
{ prisma: tx },
actor,
target.groupId,
memberBotIds,
input.artifactIds,
);
const connectorNames = await resolveOwnedConnectorDisplayNames(
tx,
actor,
mentionTargets.connectorMentionIds,
);
const blocks = buildUserMessageBlocks(input.text, attachmentBlocks);
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "user",
blocks,
replyToMessageId: input.replyToMessageId,
replyQuote,
clientNonce: input.clientNonce,
});
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: targetBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, taskId: true, botId: true, status: true },
});
const activeByBotId = new Map<string, (typeof activeRuns)[number]>();
for (const run of activeRuns) {
if (!STEERABLE_RUN_STATUSES.has(run.status)) {
throw new ORPCError("CONFLICT", {
message: "Answer the pending ask first.",
});
}
if (!activeByBotId.has(run.botId)) activeByBotId.set(run.botId, run);
}
const runs: Array<{ id: string; taskId: string; botId: string; status: string }> = [];
for (const botId of targetBotIds) {
const active = activeByBotId.get(botId);
if (active) {
await tx.steeringMessage.create({
data: { messageId: message.id, botId, userId: actor.userId, runId: active.id },
});
runs.push(active);
continue;
}
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: actor.userId,
prompt: buildSendPrompt(input.text, artifacts, connectorNames),
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "user",
clientNonce: sendRunClientNonce(input.clientNonce, message.id, botId),
sourceMessageId: message.id,
},
});
runs.push(run);
}
const firstRun = runs[0];
const eventBotId = firstRun?.botId ?? targetBotIds[0];
if (!eventBotId) throw new IsolationError("Group send did not resolve a target");
if (firstRun) {
await tx.message.update({ where: { id: message.id }, data: { runId: firstRun.id } });
const createdRuns = runs.filter((run) => !activeByBotId.has(run.botId));
if (createdRuns.length) {
await cancelSupersededQueuedRuns(tx, {
threadId: target.threadId,
botIds: createdRuns.map((run) => run.botId),
keepRunIds: createdRuns.map((run) => run.id),
});
}
}
await touchGroupUpdatedAt(tx, target.groupId);
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId: eventBotId,
type: "thread.message.created",
runId: firstRun?.id ?? activeRuns[0]?.id,
payload: {
messageId: message.id,
role: "user",
blocks,
runIds: runs.map((run) => run.id),
replyToMessageId: input.replyToMessageId,
replyQuote,
},
});
return { message, runs, eventSeq: event.seq };
});
const committed = await withSerializableRetry(commit).catch(async (error) => {
const winner = await replayExistingSend(deps, target.threadId, input.clientNonce);
if (winner) return { replay: winner } as const;
throw error;
});
if ("replay" in committed) return committed.replay;
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
// Subscribers catch up from the durable event cursor after a missed realtime wake.
getLogger().error("thread send realtime notification", error);
});
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return sendResult(committed.message, committed.runs);
}
/**
* Pick the group member bot that should receive a follow-up. Prefer a waiting
* ask/takeover on any member over the first member's merely-busy run — the
* router's old members[0] shortcut let a fresh run on bot A bury an unanswered
* ask on bot B.
*/
export function resolveGroupFollowUpBotId(input: {
text: string;
members: Array<{ botId: string; name: string }>;
activeRuns: Array<{ botId: string; status: string; createdAt: Date }>;
}): string {
const memberBotIds = new Set(input.members.map((member) => member.botId));
const memberRuns = input.activeRuns
.filter((run) => memberBotIds.has(run.botId))
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
const waiting = memberRuns.find(
(run) => run.status === "waiting_input" || run.status === "waiting_takeover",
);
if (waiting) return waiting.botId;
const steerable = memberRuns.find((run) => STEERABLE_RUN_STATUSES.has(run.status));
if (steerable) return steerable.botId;
const mentioned = resolveGroupTargetBotIds({
text: input.text,
members: input.members.map((member) => ({ id: member.botId, name: member.name })),
explicitMentions: [],
})[0];
if (mentioned) return mentioned;
const fallback = input.members[0]?.botId;
if (!fallback) throw new IsolationError();
return fallback;
}
export async function followUpGroupThreadMessage(
deps: { prisma: PrismaClient },
actor: Actor,
target: Extract<ThreadTarget, { kind: "group" }>,
input: { text: string },
) {
return deps.prisma.$transaction(async (tx) => {
const members = await lockAndLoadGroupMembers(tx, actor, target);
const memberBotIds = members.map((member) => member.botId);
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: memberBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, botId: true, status: true, createdAt: true },
orderBy: { createdAt: "desc" },
});
const botId = resolveGroupFollowUpBotId({ text: input.text, members, activeRuns });
const blocks = [{ kind: "text" as const, text: input.text }];
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "user",
blocks,
});
const active = activeRuns.find((run) => run.botId === botId);
let run: { id: string } | null = null;
if (!active) {
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: actor.userId,
prompt: input.text,
status: "queued",
},
});
run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: message.id,
},
select: { id: true },
});
await tx.message.update({ where: { id: message.id }, data: { runId: run.id } });
} else {
await tx.steeringMessage.create({
data: {
messageId: message.id,
botId,
userId: actor.userId,
runId: active.id,
},
});
await tx.message.update({ where: { id: message.id }, data: { runId: active.id } });
}
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId,
type: "thread.message.created",
runId: run?.id ?? active?.id,
payload: { messageId: message.id, role: "user", blocks },
});
await touchGroupUpdatedAt(tx, target.groupId);
return { runId: run?.id, eventSeq: event.seq };
});
}
/**
* Append an emoji reply to the conversation so the next AI turn sees its target.
* Clients render it beneath that message; the reaction itself does not start an AI turn.
*/
export async function reactToThreadMessage(
deps: { prisma: PrismaClient },
actor: Actor,
target: ThreadTarget,
input: {
messageId: string;
reaction: MessageReaction;
clientNonce: string;
},
) {
return deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const parent = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId },
select: { id: true },
});
if (!parent) throw new IsolationError();
const existing = await tx.message.findUnique({
where: {
threadId_clientNonce: { threadId: target.threadId, clientNonce: input.clientNonce },
},
select: { id: true },
});
if (existing) return { eventSeq: null };
const botId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
if (!botId) throw new IsolationError();
const blocks: MessageBlock[] = [{ kind: "text", text: input.reaction }];
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "user",
blocks,
replyToMessageId: parent.id,
clientNonce: input.clientNonce,
});
if (target.kind === "group") await touchGroupUpdatedAt(tx, target.groupId);
const event = await appendEventInTransaction(tx, {
spaceId: actor.spaceId,
threadId: target.threadId,
botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "user", blocks, replyToMessageId: parent.id },
});
return { eventSeq: event.seq };
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = ids.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: ids } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = ids.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: ids } }],
}
: { executionRunId: { in: ids } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds: ids, computers, leases };
});
// Keep the DB lease until after teardown so a replacement run cannot claim the
// screen while we still need the cancelled run's screenLeaseId to release it.
const computerById = new Map(computers.map((computer) => [computer.id, computer]));
const teardownTargets: Array<{
computer: (typeof computers)[number];
botId: string;
runId: string;
lease: (typeof leases)[number] | null;
}> = [];
const seenTargets = new Set<string>();
for (const lease of leases) {
const computer = computerById.get(lease.computerId);
if (!computer) continue;
const key = `${computer.id}:${lease.runId}`;
if (seenTargets.has(key)) continue;
seenTargets.add(key);
teardownTargets.push({
computer,
botId: lease.botId,
runId: lease.runId,
lease,
});
}
const cancelledRunIds = new Set(runIds);
for (const computer of computers) {
if (!computer.executionBotId || !computer.executionRunId) continue;
// Computers may enter the snapshot via a cancelled lease while
// Computer.executionRunId still points at an unrelated live run. Only treat
// the legacy columns as a teardown target when they belong to a run we
// cancelled in this transaction.
if (!cancelledRunIds.has(computer.executionRunId)) continue;
const key = `${computer.id}:${computer.executionRunId}`;
if (seenTargets.has(key)) continue;
seenTargets.add(key);
teardownTargets.push({
computer,
botId: computer.executionBotId,
runId: computer.executionRunId,
lease: null,
});
}
await Promise.all(
teardownTargets.map(async ({ computer, botId, runId, lease }) => {
if (!computer.providerRef) return;
const context = {
operationId: "stop",
traceId: "stop",
spaceId: actor.spaceId,
userId: actor.userId,
botId,
runId,
screenLeaseId: screenLeaseIdForRun(lease, runId),
cancelRunWork: true,
signal: new AbortController().signal,
};
const ref = toComputerRef(computer);
await cancelComputerRunWork(deps.sandbox, ref, computer.id, runId, context);
await deps.sandbox.releaseScreen?.(ref, context).catch(() => undefined);
}),
);
await expireComputerExecutionLeases(deps.prisma, { runId: { in: runIds } });
await deps.prisma.computer.updateMany({
where: { executionRunId: { in: runIds } },
data: {
executionRunId: null,
executionBotId: null,
executionLeaseExpiresAt: null,
},
});
await deps.prisma.event.deleteMany({
where: {
type: "thread.progress",
runId: { in: runIds },
},
});
}
export async function setThreadUnreadState(
prisma: PrismaClient,
actor: Actor,
target: ThreadTarget,
unread: boolean,
) {
const result = await prisma.thread.updateMany({
where: {
id: target.threadId,
spaceId: actor.spaceId,
userId: actor.userId,
unread: { not: unread },
},
data: { unread },
});
if (result.count > 1) throw new IsolationError();
}