Add thread execution conflict guards to chat flows

This commit is contained in:
kris
2026-04-06 12:01:06 +08:00
parent 2c47df702e
commit 9d7d2f4d17
10 changed files with 690 additions and 24 deletions

View File

@@ -2,10 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data";
import {
getThreadConversationExecutionConflict,
queueGroupDispatchPlan,
queueThreadConversationReplyTask,
replyToMasterAgentUserMessage,
shouldRecommendMasterAgentDispatchPlan,
ThreadConversationExecutionConflictError,
} from "@/lib/boss-master-agent";
import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy";
@@ -26,6 +28,8 @@ function threadConversationFailureMessage(error?: string) {
return "当前线程还没有绑定真实 Codex 线程,请先重新导入该线程后再试。";
case "THREAD_TARGET_DEVICE_OFFLINE":
return "当前线程所在设备不在线,请先让对应设备上线后再试。";
case "THREAD_EXECUTION_CONFLICT":
return "当前线程命中了 GUI / CLI 冲突保护,请先确认本项目是否放行后再继续发送。";
default:
return error ?? "UNKNOWN_ERROR";
}
@@ -80,6 +84,27 @@ export async function POST(
);
}
const singleThreadExecutionConflict =
project &&
projectId !== "master-agent" &&
!project.isGroup &&
(body.kind ?? "text") === "text" &&
(body.body ?? "").trim().length > 0
? await getThreadConversationExecutionConflict(projectId)
: null;
if (singleThreadExecutionConflict) {
return NextResponse.json(
{
ok: false,
code: "THREAD_EXECUTION_CONFLICT",
message: threadConversationFailureMessage("THREAD_EXECUTION_CONFLICT"),
executionConflict: singleThreadExecutionConflict,
},
{ status: 409 },
);
}
const message = await appendProjectMessage({
projectId,
senderLabel: session.displayName || "你",
@@ -209,6 +234,17 @@ export async function POST(
collaborationGate,
});
} catch (error) {
if (error instanceof ThreadConversationExecutionConflictError) {
return NextResponse.json(
{
ok: false,
code: error.message,
message: threadConversationFailureMessage(error.message),
executionConflict: error.conflict,
},
{ status: 409 },
);
}
const reason = error instanceof Error ? error.message : "UNKNOWN_ERROR";
return NextResponse.json(
{

View File

@@ -21,6 +21,15 @@ import {
summarizeDispatchPlanCompact,
summarizeDispatchPlanLightTitle,
} from "@/lib/dispatch-plan-ui";
import type {
ThreadConversationExecutionConflict,
ThreadConversationExecutionConflictAction,
} from "@/lib/thread-execution-conflict";
import {
describeThreadConversationExecutionConflict,
labelForThreadConversationExecutionConflictDecision,
summarizeThreadConversationExecutionDecisionResult,
} from "@/lib/thread-execution-conflict-ui";
import type {
Device,
DeviceEnrollment,
@@ -1195,6 +1204,7 @@ export function ChatComposer({
initialLightDispatchReminderEnabled?: boolean;
}) {
const router = useRouter();
type ComposerMessageKind = "text" | "voice_intent" | "image_intent" | "video_intent";
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
@@ -1207,6 +1217,11 @@ export function ChatComposer({
const [lightDispatchReminderEnabled, setLightDispatchReminderEnabled] = useState(
initialLightDispatchReminderEnabled,
);
const [threadExecutionConflict, setThreadExecutionConflict] = useState<{
conflict: ThreadConversationExecutionConflict;
draftBody: string;
kind: ComposerMessageKind;
} | null>(null);
const pendingDispatchPlan =
localPendingDispatchPlan ??
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
@@ -1214,6 +1229,9 @@ export function ChatComposer({
: null);
const rejectedDispatchPlan =
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
const threadExecutionConflictDescription = threadExecutionConflict
? describeThreadConversationExecutionConflict(threadExecutionConflict.conflict)
: null;
async function confirmDispatchPlan(rememberLightReminder = false) {
if (!pendingDispatchPlan) return;
@@ -1352,16 +1370,25 @@ export function ChatComposer({
router.refresh();
}
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
async function send(
kind: ComposerMessageKind,
options?: {
draftBody?: string;
},
) {
const draftBody = kind === "text" ? (options?.draftBody ?? value).trim() : "";
if (kind === "text" && !draftBody) {
return;
}
setLoading(true);
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
body: JSON.stringify({ body: kind === "text" ? draftBody : undefined, kind }),
});
const result = (await response.json()) as {
ok: boolean;
message?: { body: string };
message?: { body: string } | string;
dispatchPlan?: {
planId: string;
summary?: string;
@@ -1371,17 +1398,30 @@ export function ChatComposer({
requiresMasterAgentApproval?: boolean;
lightDispatchReminderEnabled?: boolean;
};
code?: string;
executionConflict?: ThreadConversationExecutionConflict;
messageText?: string;
};
setLoading(false);
if (!result.ok && response.status === 409 && result.code === "THREAD_EXECUTION_CONFLICT" && result.executionConflict) {
setThreadExecutionConflict({
conflict: result.executionConflict,
draftBody,
kind,
});
setMessageTone("error");
setMessage(typeof result.message === "string" ? result.message : "当前线程命中了 GUI / CLI 冲突保护。");
return;
}
if (result.ok) {
setThreadExecutionConflict(null);
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
projectId,
level: "info",
category: "chat.message_sent",
message:
kind === "text" ? `已发送文本消息:${value.trim() || "空文本"}` : `已发送 ${kind} 意图消息。`,
kind === "text" ? `已发送文本消息:${draftBody || "空文本"}` : `已发送 ${kind} 意图消息。`,
mirrorToMaster: false,
});
setValue("");
@@ -1419,7 +1459,44 @@ export function ChatComposer({
mirrorToMaster: true,
});
setMessageTone("error");
setMessage("消息发送失败,请重试。");
setMessage(typeof result.message === "string" ? result.message : "消息发送失败,请重试。");
}
async function handleThreadExecutionConflictDecision(
decision: ThreadConversationExecutionConflictAction,
) {
if (!threadExecutionConflict) {
return;
}
setLoading(true);
const response = await fetch(`/api/v1/devices/${threadExecutionConflict.conflict.deviceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: threadExecutionConflict.conflict.projectId,
folderKey: threadExecutionConflict.conflict.folderKey ?? null,
conflictDecision: decision,
}),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "冲突放行设置失败,请重试。");
return;
}
const pendingDraft = threadExecutionConflict;
setThreadExecutionConflict(null);
setMessageTone(decision === "forbid" ? "error" : "success");
setMessage(summarizeThreadConversationExecutionDecisionResult(decision));
if (decision === "forbid") {
return;
}
await send(pendingDraft.kind, { draftBody: pendingDraft.draftBody });
}
return (
@@ -1477,6 +1554,40 @@ export function ChatComposer({
{dispatchPlanRecoveryHint}
</div>
) : null}
{threadExecutionConflict && threadExecutionConflictDescription ? (
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
<div className="text-[14px] font-semibold text-[#111111]">
{threadExecutionConflictDescription.title}
</div>
<div className="mt-2">{threadExecutionConflictDescription.summary}</div>
<div className="mt-2 text-[12px] text-[#8C8C8C]">
{threadExecutionConflict.conflict.deviceName}
{" · "}
{threadExecutionConflict.conflict.preferredExecutionMode === "gui" ? "GUI" : "CLI"}
{threadExecutionConflict.conflict.folderKey ? ` · ${threadExecutionConflict.conflict.folderKey}` : ""}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{threadExecutionConflict.conflict.actions.map((action) => (
<button
key={action}
type="button"
disabled={loading}
onClick={() => void handleThreadExecutionConflictDecision(action)}
className={clsx(
"rounded-full px-4 py-2 text-[13px] font-semibold disabled:opacity-60",
action === "allow_once"
? "bg-[#07C160] text-white"
: action === "allow_always"
? "bg-[#2563EB] text-white"
: "border border-[#F0B5B5] text-[#CF1322]",
)}
>
{labelForThreadConversationExecutionConflictDecision(action)}
</button>
))}
</div>
</div>
) : null}
{pendingDispatchPlan ? (
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
<div className="text-[14px] font-semibold text-[#111111]">

View File

@@ -25,9 +25,12 @@ import type {
AiProvider,
DispatchPlanTarget,
Project,
ProjectExecutionPolicy,
ProjectAgentControls,
ReasoningEffort,
} from "@/lib/boss-data";
import type { ThreadConversationExecutionConflict } from "@/lib/thread-execution-conflict";
import { THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS } from "@/lib/thread-execution-conflict";
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
import {
CLAW_BACKEND_ID,
@@ -91,6 +94,16 @@ type QueuedMasterAgentReplyEnvelope = {
};
};
export class ThreadConversationExecutionConflictError extends Error {
conflict: ThreadConversationExecutionConflict;
constructor(conflict: ThreadConversationExecutionConflict) {
super("THREAD_EXECUTION_CONFLICT");
this.name = "ThreadConversationExecutionConflictError";
this.conflict = conflict;
}
}
export async function resolveMasterAgentExecutionConfig(
projectId: string,
accountId?: string,
@@ -226,6 +239,116 @@ function buildThreadConversationReplyPrompt(project: Project, requestText: strin
].join("\n");
}
function buildThreadConversationFolderKey(project: Project) {
const deviceId = project.deviceIds[0];
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
if (!deviceId || !folderRef) {
return undefined;
}
return `${deviceId}:${folderRef}`;
}
function findThreadConflictPolicy(
policies: ProjectExecutionPolicy[],
input: {
deviceId: string;
projectId: string;
folderKey?: string;
},
) {
if (input.folderKey) {
const folderMatch = policies.find(
(policy) => policy.deviceId === input.deviceId && policy.folderKey === input.folderKey,
);
if (folderMatch) {
return folderMatch;
}
}
return policies.find(
(policy) => policy.deviceId === input.deviceId && policy.projectId === input.projectId,
);
}
async function resolveThreadConversationExecutionContext(projectId: string) {
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
throw new Error("PROJECT_NOT_FOUND");
}
if (project.isGroup) {
throw new Error("PROJECT_NOT_SINGLE_THREAD");
}
if (project.id === "master-agent") {
throw new Error("PROJECT_NOT_THREAD_CONVERSATION");
}
if (!project.threadMeta.codexThreadRef?.trim()) {
throw new Error("THREAD_BINDING_REQUIRED");
}
const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio";
const device = state.devices.find((item) => item.id === deviceId);
if (!device || device.status !== "online") {
throw new Error("THREAD_TARGET_DEVICE_OFFLINE");
}
const folderKey = buildThreadConversationFolderKey(project);
const matchingPolicy = findThreadConflictPolicy(state.projectExecutionPolicies, {
deviceId,
projectId: project.id,
folderKey,
});
return {
project,
device,
deviceId,
folderKey,
matchingPolicy,
};
}
export async function getThreadConversationExecutionConflict(projectId: string) {
const context = await resolveThreadConversationExecutionContext(projectId);
const { project, device, deviceId, folderKey, matchingPolicy } = context;
const preferredExecutionMode = device.preferredExecutionMode ?? "cli";
if (matchingPolicy?.allowPolicy === "allow_once" || matchingPolicy?.allowPolicy === "allow_always") {
return null;
}
if (preferredExecutionMode === "gui") {
return {
projectId: project.id,
projectName: project.name,
deviceId,
deviceName: device.name,
folderKey,
preferredExecutionMode,
allowPolicy: matchingPolicy?.allowPolicy ?? "forbid",
conflictState: matchingPolicy?.conflictState ?? "blocked",
reason: "preferred_gui_mode" as const,
actions: [...THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS],
};
}
if (matchingPolicy?.conflictState === "blocked" && matchingPolicy.allowPolicy === "forbid") {
return {
projectId: project.id,
projectName: project.name,
deviceId,
deviceName: device.name,
folderKey,
preferredExecutionMode,
allowPolicy: matchingPolicy.allowPolicy,
conflictState: matchingPolicy.conflictState,
reason: "project_conflict_forbid" as const,
actions: [...THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS],
};
}
return null;
}
function buildRuntimeDigest(
state: Awaited<ReturnType<typeof readState>>,
requestText: string,
@@ -1701,26 +1824,11 @@ export async function queueThreadConversationReplyTask(params: {
requestedBy: string;
requestedByAccount: string;
}) {
const state = await readState();
const project = state.projects.find((item) => item.id === params.projectId);
if (!project) {
throw new Error("PROJECT_NOT_FOUND");
}
if (project.isGroup) {
throw new Error("PROJECT_NOT_SINGLE_THREAD");
}
if (project.id === "master-agent") {
throw new Error("PROJECT_NOT_THREAD_CONVERSATION");
}
if (!project.threadMeta.codexThreadRef?.trim()) {
throw new Error("THREAD_BINDING_REQUIRED");
}
const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio";
const device = state.devices.find((item) => item.id === deviceId);
if (!device || device.status !== "online") {
throw new Error("THREAD_TARGET_DEVICE_OFFLINE");
const conflict = await getThreadConversationExecutionConflict(params.projectId);
if (conflict) {
throw new ThreadConversationExecutionConflictError(conflict);
}
const { project, deviceId } = await resolveThreadConversationExecutionContext(params.projectId);
return queueMasterAgentTask({
projectId: project.id,
taskType: "conversation_reply",

View File

@@ -0,0 +1,48 @@
import type {
ThreadConversationExecutionConflict,
ThreadConversationExecutionConflictAction,
} from "@/lib/thread-execution-conflict";
export function describeThreadConversationExecutionConflict(
conflict: ThreadConversationExecutionConflict,
) {
if (conflict.reason === "preferred_gui_mode") {
return {
title: "当前项目默认先走 GUI",
summary: `${conflict.deviceName} 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 ${conflict.projectName},需要你先对这个项目放行;这个选择只对这个项目生效。`,
};
}
return {
title: "当前项目已命中并发保护",
summary: `${conflict.projectName} 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。`,
};
}
export function labelForThreadConversationExecutionConflictDecision(
decision: ThreadConversationExecutionConflictAction,
) {
switch (decision) {
case "allow_once":
return "允许本次";
case "allow_always":
return "永久放行";
case "forbid":
default:
return "禁止";
}
}
export function summarizeThreadConversationExecutionDecisionResult(
decision: ThreadConversationExecutionConflictAction,
) {
switch (decision) {
case "allow_once":
return "已允许本次,继续发送中…";
case "allow_always":
return "已对当前项目永久放行,继续发送中…";
case "forbid":
default:
return "已保持禁止,这次消息没有发出。";
}
}

View File

@@ -0,0 +1,25 @@
export type ThreadConversationExecutionConflictAction = "forbid" | "allow_once" | "allow_always";
export type ThreadConversationExecutionConflictState = "none" | "warning" | "blocked";
export type ThreadConversationExecutionPreferredMode = "gui" | "cli";
export type ThreadConversationExecutionConflictReason =
| "preferred_gui_mode"
| "project_conflict_forbid";
export interface ThreadConversationExecutionConflict {
projectId: string;
projectName: string;
deviceId: string;
deviceName: string;
folderKey?: string;
preferredExecutionMode: ThreadConversationExecutionPreferredMode;
allowPolicy: ThreadConversationExecutionConflictAction;
conflictState: ThreadConversationExecutionConflictState;
reason: ThreadConversationExecutionConflictReason;
actions: ThreadConversationExecutionConflictAction[];
}
export const THREAD_CONVERSATION_EXECUTION_CONFLICT_ACTIONS: ThreadConversationExecutionConflictAction[] = [
"forbid",
"allow_once",
"allow_always",
];