Add thread execution conflict guards to chat flows
This commit is contained in:
@@ -81,6 +81,48 @@ public final class ProjectChatUiState {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中冲突保护";
|
||||
}
|
||||
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
|
||||
return "当前项目默认先走 GUI";
|
||||
}
|
||||
return "当前项目已命中并发保护";
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。";
|
||||
}
|
||||
String projectName = conflict.optString("projectName", "当前项目");
|
||||
String deviceName = conflict.optString("deviceName", "当前设备");
|
||||
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
|
||||
return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。";
|
||||
}
|
||||
return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。";
|
||||
}
|
||||
|
||||
public static String labelForThreadExecutionConflictDecision(@Nullable String decision) {
|
||||
if ("allow_once".equals(decision)) {
|
||||
return "允许本次";
|
||||
}
|
||||
if ("allow_always".equals(decision)) {
|
||||
return "永久放行";
|
||||
}
|
||||
return "禁止";
|
||||
}
|
||||
|
||||
public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) {
|
||||
if ("allow_once".equals(decision)) {
|
||||
return "已允许本次,继续发送中…";
|
||||
}
|
||||
if ("allow_always".equals(decision)) {
|
||||
return "已对当前项目永久放行,继续发送中…";
|
||||
}
|
||||
return "已保持禁止,这次消息没有发出。";
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
@@ -649,7 +649,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
|
||||
JSONObject executionConflict = response.json.optJSONObject("executionConflict");
|
||||
if (!response.ok()) {
|
||||
if (response.statusCode == 409
|
||||
&& "THREAD_EXECUTION_CONFLICT".equals(response.json.optString("code", ""))
|
||||
&& executionConflict != null) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
removePendingOutgoingBubble();
|
||||
updateComposerSendButtonState();
|
||||
showThreadExecutionConflictDialog(executionConflict, body, kind);
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
|
||||
@@ -702,6 +715,85 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) {
|
||||
String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli"))
|
||||
? "GUI"
|
||||
: "CLI";
|
||||
String deviceName = executionConflict.optString("deviceName", "当前设备");
|
||||
String folderKey = executionConflict.optString("folderKey", "").trim();
|
||||
StringBuilder messageBuilder = new StringBuilder(ProjectChatUiState.threadExecutionConflictSummary(executionConflict))
|
||||
.append("\n\n设备:")
|
||||
.append(deviceName)
|
||||
.append(" · 默认模式:")
|
||||
.append(preferredMode);
|
||||
if (!folderKey.isEmpty()) {
|
||||
messageBuilder.append("\n范围:").append(folderKey);
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(ProjectChatUiState.threadExecutionConflictTitle(executionConflict))
|
||||
.setMessage(messageBuilder.toString())
|
||||
.setNegativeButton(
|
||||
ProjectChatUiState.labelForThreadExecutionConflictDecision("forbid"),
|
||||
(dialog, which) -> {
|
||||
showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult("forbid"));
|
||||
updateComposerSendButtonState();
|
||||
}
|
||||
)
|
||||
.setNeutralButton(
|
||||
ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"),
|
||||
(dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_once", body, kind)
|
||||
)
|
||||
.setPositiveButton(
|
||||
ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"),
|
||||
(dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_always", body, kind)
|
||||
)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void applyThreadExecutionConflictDecision(
|
||||
JSONObject executionConflict,
|
||||
String decision,
|
||||
String body,
|
||||
String kind
|
||||
) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String folderKey = executionConflict.optString("folderKey", "").trim();
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
|
||||
executionConflict.optString("deviceId", ""),
|
||||
executionConflict.optString("projectId", ""),
|
||||
folderKey.isEmpty() ? null : folderKey,
|
||||
decision
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult(decision));
|
||||
if ("forbid".equals(decision)) {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
updateComposerSendButtonState();
|
||||
return;
|
||||
}
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
sendProjectMessage(kind, body);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
updateComposerSendButtonState();
|
||||
showMessage("冲突放行设置失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
|
||||
@@ -249,4 +249,27 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1"));
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
.put("projectName", "Boss UI 主线程")
|
||||
.put("deviceName", "Mac Studio")
|
||||
.put("reason", "preferred_gui_mode");
|
||||
|
||||
assertEquals("当前项目默认先走 GUI", ProjectChatUiState.threadExecutionConflictTitle(conflict));
|
||||
assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只对这个项目生效"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsForbidAsProjectOnly() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
.put("projectName", "Boss UI 主线程")
|
||||
.put("reason", "project_conflict_forbid");
|
||||
|
||||
assertEquals("当前项目已命中并发保护", ProjectChatUiState.threadExecutionConflictTitle(conflict));
|
||||
assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只影响这个项目"));
|
||||
assertEquals("允许本次", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"));
|
||||
assertEquals("永久放行", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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",
|
||||
|
||||
48
src/lib/thread-execution-conflict-ui.ts
Normal file
48
src/lib/thread-execution-conflict-ui.ts
Normal 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 "已保持禁止,这次消息没有发出。";
|
||||
}
|
||||
}
|
||||
25
src/lib/thread-execution-conflict.ts
Normal file
25
src/lib/thread-execution-conflict.ts
Normal 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",
|
||||
];
|
||||
@@ -100,6 +100,11 @@ function buildSingleThreadProject(projectId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectFolderKey(project: ReturnType<typeof buildSingleThreadProject>) {
|
||||
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
|
||||
return `${project.deviceIds[0]}:${folderRef}`;
|
||||
}
|
||||
|
||||
async function ensureSingleThreadProject() {
|
||||
const state = await readState();
|
||||
const existing = findSingleThreadProject(state);
|
||||
@@ -159,6 +164,131 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
|
||||
assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
|
||||
const state = await readState();
|
||||
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
|
||||
assert.ok(targetDevice, "expected a seeded target device");
|
||||
targetDevice.preferredExecutionMode = "gui";
|
||||
await writeState(state);
|
||||
|
||||
const response = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
|
||||
"POST",
|
||||
{ body: "继续推进当前线程" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: singleProject.id }) },
|
||||
);
|
||||
assert.equal(response.status, 409);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
message?: string;
|
||||
executionConflict?: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
preferredExecutionMode: "gui" | "cli";
|
||||
allowPolicy: "forbid" | "allow_once" | "allow_always";
|
||||
conflictState: "none" | "warning" | "blocked";
|
||||
reason: string;
|
||||
actions: string[];
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
|
||||
assert.equal(payload.executionConflict?.projectId, singleProject.id);
|
||||
assert.equal(payload.executionConflict?.deviceId, singleProject.deviceIds[0]);
|
||||
assert.equal(payload.executionConflict?.preferredExecutionMode, "gui");
|
||||
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
|
||||
assert.equal(payload.executionConflict?.conflictState, "blocked");
|
||||
assert.equal(payload.executionConflict?.reason, "preferred_gui_mode");
|
||||
assert.deepEqual(payload.executionConflict?.actions, ["forbid", "allow_once", "allow_always"]);
|
||||
|
||||
const nextState = await readState();
|
||||
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
|
||||
const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续推进当前线程"));
|
||||
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
|
||||
const queuedTask = nextState.masterAgentTasks.find(
|
||||
(item) =>
|
||||
item.taskType === "conversation_reply" &&
|
||||
item.projectId === singleProject.id &&
|
||||
item.requestText === "继续推进当前线程",
|
||||
);
|
||||
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the current project folder is forbidden", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
assert.ok(singleProject, "expected a seeded single-thread project");
|
||||
|
||||
const state = await readState();
|
||||
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
|
||||
assert.ok(targetDevice, "expected a seeded target device");
|
||||
targetDevice.preferredExecutionMode = "cli";
|
||||
state.projectExecutionPolicies = [
|
||||
{
|
||||
deviceId: singleProject.deviceIds[0],
|
||||
folderKey: buildProjectFolderKey(singleProject),
|
||||
projectId: singleProject.id,
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
updatedAt: "2026-04-06T13:20:00.000Z",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const response = await postMessageRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
|
||||
"POST",
|
||||
{ body: "继续同步项目进度" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: singleProject.id }) },
|
||||
);
|
||||
assert.equal(response.status, 409);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
executionConflict?: {
|
||||
projectId: string;
|
||||
folderKey?: string;
|
||||
preferredExecutionMode: "gui" | "cli";
|
||||
allowPolicy: "forbid" | "allow_once" | "allow_always";
|
||||
conflictState: "none" | "warning" | "blocked";
|
||||
reason: string;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
|
||||
assert.equal(payload.executionConflict?.projectId, singleProject.id);
|
||||
assert.equal(payload.executionConflict?.folderKey, buildProjectFolderKey(singleProject));
|
||||
assert.equal(payload.executionConflict?.preferredExecutionMode, "cli");
|
||||
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
|
||||
assert.equal(payload.executionConflict?.conflictState, "blocked");
|
||||
assert.equal(payload.executionConflict?.reason, "project_conflict_forbid");
|
||||
|
||||
const nextState = await readState();
|
||||
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
|
||||
const blockedMessage = updatedProject?.messages.find((message) => message.body.includes("继续同步项目进度"));
|
||||
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
|
||||
const queuedTask = nextState.masterAgentTasks.find(
|
||||
(item) =>
|
||||
item.taskType === "conversation_reply" &&
|
||||
item.projectId === singleProject.id &&
|
||||
item.requestText === "继续同步项目进度",
|
||||
);
|
||||
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
|
||||
});
|
||||
|
||||
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => {
|
||||
await setup();
|
||||
const singleProject = await ensureSingleThreadProject();
|
||||
|
||||
51
tests/thread-execution-conflict-ui.test.ts
Normal file
51
tests/thread-execution-conflict-ui.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
describeThreadConversationExecutionConflict,
|
||||
labelForThreadConversationExecutionConflictDecision,
|
||||
} from "../src/lib/thread-execution-conflict-ui.ts";
|
||||
|
||||
test("describeThreadConversationExecutionConflict explains preferred gui mode with project-scoped guidance", () => {
|
||||
const description = describeThreadConversationExecutionConflict({
|
||||
projectId: "thread-ui",
|
||||
projectName: "Boss UI 主线程",
|
||||
deviceId: "mac-studio",
|
||||
deviceName: "Mac Studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
preferredExecutionMode: "gui",
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
reason: "preferred_gui_mode",
|
||||
actions: ["forbid", "allow_once", "allow_always"],
|
||||
});
|
||||
|
||||
assert.equal(description.title, "当前项目默认先走 GUI");
|
||||
assert.match(description.summary, /Mac Studio/);
|
||||
assert.match(description.summary, /只对这个项目/);
|
||||
assert.match(description.summary, /Boss UI 主线程/);
|
||||
});
|
||||
|
||||
test("describeThreadConversationExecutionConflict explains project-level forbid without implying a global lock", () => {
|
||||
const description = describeThreadConversationExecutionConflict({
|
||||
projectId: "thread-ui",
|
||||
projectName: "Boss UI 主线程",
|
||||
deviceId: "mac-studio",
|
||||
deviceName: "Mac Studio",
|
||||
folderKey: "mac-studio:boss",
|
||||
preferredExecutionMode: "cli",
|
||||
allowPolicy: "forbid",
|
||||
conflictState: "blocked",
|
||||
reason: "project_conflict_forbid",
|
||||
actions: ["forbid", "allow_once", "allow_always"],
|
||||
});
|
||||
|
||||
assert.equal(description.title, "当前项目已命中并发保护");
|
||||
assert.match(description.summary, /最近检测到 GUI \/ CLI 同时活动/);
|
||||
assert.match(description.summary, /只影响这个项目/);
|
||||
});
|
||||
|
||||
test("labelForThreadConversationExecutionConflictDecision keeps the three project-scoped actions concise", () => {
|
||||
assert.equal(labelForThreadConversationExecutionConflictDecision("forbid"), "禁止");
|
||||
assert.equal(labelForThreadConversationExecutionConflictDecision("allow_once"), "允许本次");
|
||||
assert.equal(labelForThreadConversationExecutionConflictDecision("allow_always"), "永久放行");
|
||||
});
|
||||
Reference in New Issue
Block a user