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

@@ -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",
];