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

@@ -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();

View 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"), "永久放行");
});