Add thread execution conflict guards to chat flows
This commit is contained in:
@@ -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",
|
||||
];
|
||||
Reference in New Issue
Block a user