317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
|
|
let runtimeRoot = "";
|
|
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
|
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
|
let detectProjectExecutionConflict: (typeof import("../src/lib/boss-data"))["detectProjectExecutionConflict"];
|
|
let applyProjectConflictDecision: (typeof import("../src/lib/boss-data"))["applyProjectConflictDecision"];
|
|
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
|
|
let claimNextMasterAgentTask: (typeof import("../src/lib/boss-data"))["claimNextMasterAgentTask"];
|
|
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
|
|
let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"];
|
|
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
|
|
|
|
async function setup() {
|
|
if (runtimeRoot) return;
|
|
|
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-conflict-"));
|
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
|
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
|
|
|
const data = await import("../src/lib/boss-data.ts");
|
|
readState = data.readState;
|
|
writeState = data.writeState;
|
|
detectProjectExecutionConflict = data.detectProjectExecutionConflict;
|
|
applyProjectConflictDecision = data.applyProjectConflictDecision;
|
|
queueMasterAgentTask = data.queueMasterAgentTask;
|
|
claimNextMasterAgentTask = data.claimNextMasterAgentTask;
|
|
completeMasterAgentTask = data.completeMasterAgentTask;
|
|
updateDevice = data.updateDevice;
|
|
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
|
}
|
|
|
|
test.after(async () => {
|
|
if (runtimeRoot) {
|
|
await rm(runtimeRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
await setup();
|
|
await rm(runtimeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function buildProjectFolderKey(project: Awaited<ReturnType<typeof readState>>["projects"][number]) {
|
|
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
|
|
return `${project.deviceIds[0]}:${folderRef}`;
|
|
}
|
|
|
|
async function getCliProject() {
|
|
const state = await readState();
|
|
let project = state.projects.find(
|
|
(item) => !item.isGroup && item.id !== "master-agent" && item.deviceIds.includes("mac-studio"),
|
|
);
|
|
if (!project) {
|
|
project = {
|
|
id: "thread-ui",
|
|
name: "Boss UI",
|
|
pinned: false,
|
|
deviceIds: ["mac-studio"],
|
|
preview: "线程执行中",
|
|
updatedAt: "2026-04-06T10:00:00.000Z",
|
|
lastMessageAt: "2026-04-06T10:00:00.000Z",
|
|
isGroup: false,
|
|
threadMeta: {
|
|
projectId: "thread-ui",
|
|
threadId: "thread-ui-main",
|
|
threadDisplayName: "Boss UI 主线程",
|
|
folderName: "boss",
|
|
activityIconCount: 1,
|
|
updatedAt: "2026-04-06T10:00:00.000Z",
|
|
codexThreadRef: "thread-ui-main",
|
|
codexFolderRef: "boss",
|
|
},
|
|
groupMembers: [],
|
|
createdByAgent: true,
|
|
collaborationMode: "development",
|
|
approvalState: "not_required",
|
|
unreadCount: 0,
|
|
riskLevel: "medium",
|
|
contextBudgetPct: 64,
|
|
contextBudgetLabel: "64%",
|
|
messages: [],
|
|
goals: [],
|
|
versions: [],
|
|
};
|
|
state.projects.push(project);
|
|
await writeState(state);
|
|
}
|
|
return project;
|
|
}
|
|
|
|
test("detectProjectExecutionConflict blocks cli execution when the same folder has new external activity", async () => {
|
|
await setup();
|
|
|
|
const state = await readState();
|
|
state.projectExecutionPolicies = [];
|
|
await writeState(state);
|
|
|
|
const result = await detectProjectExecutionConflict({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:boss",
|
|
projectId: "thread-ui",
|
|
executionMode: "cli",
|
|
activityAt: "2026-04-06T10:05:00.000Z",
|
|
externalActivityAt: "2026-04-06T10:04:00.000Z",
|
|
});
|
|
|
|
assert.equal(result.blocked, true);
|
|
assert.equal(result.policy.allowPolicy, "forbid");
|
|
assert.equal(result.policy.conflictState, "blocked");
|
|
});
|
|
|
|
test("allow_once only clears the active folder conflict after a single execution", async () => {
|
|
await setup();
|
|
|
|
await applyProjectConflictDecision({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:boss",
|
|
projectId: "thread-ui",
|
|
decision: "allow_once",
|
|
});
|
|
|
|
let result = await detectProjectExecutionConflict({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:boss",
|
|
projectId: "thread-ui",
|
|
executionMode: "cli",
|
|
activityAt: "2026-04-06T10:10:00.000Z",
|
|
externalActivityAt: "2026-04-06T10:09:00.000Z",
|
|
});
|
|
assert.equal(result.blocked, false);
|
|
assert.equal(result.policy.allowPolicy, "allow_once");
|
|
|
|
result = await detectProjectExecutionConflict({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:boss",
|
|
projectId: "thread-ui",
|
|
executionMode: "cli",
|
|
activityAt: "2026-04-06T10:20:00.000Z",
|
|
externalActivityAt: "2026-04-06T10:19:00.000Z",
|
|
});
|
|
assert.equal(result.blocked, false);
|
|
assert.equal(result.policy.allowPolicy, "allow_once");
|
|
});
|
|
|
|
test("allow_always applies only to the active folder and does not unlock other folders on the same device", async () => {
|
|
await setup();
|
|
|
|
await applyProjectConflictDecision({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:boss",
|
|
projectId: "thread-ui",
|
|
decision: "allow_always",
|
|
});
|
|
|
|
const allowed = await detectProjectExecutionConflict({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:boss",
|
|
projectId: "thread-ui",
|
|
executionMode: "cli",
|
|
activityAt: "2026-04-06T10:30:00.000Z",
|
|
externalActivityAt: "2026-04-06T10:29:00.000Z",
|
|
});
|
|
assert.equal(allowed.blocked, false);
|
|
assert.equal(allowed.policy.allowPolicy, "allow_always");
|
|
|
|
const blocked = await detectProjectExecutionConflict({
|
|
deviceId: "mac-studio",
|
|
folderKey: "mac-studio:talking",
|
|
projectId: "thread-talking",
|
|
executionMode: "cli",
|
|
activityAt: "2026-04-06T10:31:00.000Z",
|
|
externalActivityAt: "2026-04-06T10:30:00.000Z",
|
|
});
|
|
assert.equal(blocked.blocked, true);
|
|
assert.equal(blocked.policy.allowPolicy, "forbid");
|
|
});
|
|
|
|
test("claimNextMasterAgentTask keeps conversation replies queued when the device prefers gui mode", async () => {
|
|
await setup();
|
|
|
|
const project = await getCliProject();
|
|
await updateDevice("mac-studio", {
|
|
preferredExecutionMode: "gui",
|
|
});
|
|
const task = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-preferred-gui",
|
|
requestText: "继续推进当前线程任务",
|
|
executionPrompt: "请继续推进当前线程任务",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "17600003315",
|
|
deviceId: "mac-studio",
|
|
taskType: "conversation_reply",
|
|
targetProjectId: project.id,
|
|
targetThreadId: project.threadMeta.threadId,
|
|
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
|
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
|
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
|
});
|
|
|
|
const claimed = await claimNextMasterAgentTask("mac-studio");
|
|
|
|
assert.equal(claimed, null);
|
|
const state = await readState();
|
|
const queued = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
|
assert.equal(queued?.status, "queued");
|
|
});
|
|
|
|
test("heartbeat external activity on an active cli folder blocks the next claim until the user explicitly allows it", async () => {
|
|
await setup();
|
|
|
|
const project = await getCliProject();
|
|
const folderKey = buildProjectFolderKey(project);
|
|
|
|
const firstTask = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-first",
|
|
requestText: "先推进一轮",
|
|
executionPrompt: "请先推进一轮",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "17600003315",
|
|
deviceId: "mac-studio",
|
|
taskType: "conversation_reply",
|
|
targetProjectId: project.id,
|
|
targetThreadId: project.threadMeta.threadId,
|
|
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
|
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
|
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
|
});
|
|
const claimedFirst = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(claimedFirst?.taskId, firstTask.taskId);
|
|
|
|
await upsertDeviceHeartbeat({
|
|
deviceId: "mac-studio",
|
|
name: "Mac Studio",
|
|
avatar: "M",
|
|
account: "17600003315",
|
|
status: "online",
|
|
quota5h: 72,
|
|
quota7d: 86,
|
|
projects: [project.threadMeta.folderName],
|
|
projectCandidates: [
|
|
{
|
|
folderName: project.threadMeta.folderName,
|
|
folderRef: project.threadMeta.codexFolderRef,
|
|
threadId: project.threadMeta.threadId,
|
|
threadDisplayName: project.threadMeta.threadDisplayName,
|
|
codexFolderRef: project.threadMeta.codexFolderRef,
|
|
codexThreadRef: project.threadMeta.codexThreadRef,
|
|
lastActiveAt: "2026-04-06T11:05:00.000Z",
|
|
suggestedImport: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
let state = await readState();
|
|
let policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
|
assert.ok(policy, "expected heartbeat to persist a scoped conflict policy");
|
|
assert.equal(policy?.activeCliExecution, true);
|
|
assert.equal(policy?.conflictState, "blocked");
|
|
assert.equal(policy?.recentExternalActivityAt, "2026-04-06T11:05:00.000Z");
|
|
|
|
const secondTask = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-second",
|
|
requestText: "继续推进第二轮",
|
|
executionPrompt: "请继续推进第二轮",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "17600003315",
|
|
deviceId: "mac-studio",
|
|
taskType: "conversation_reply",
|
|
targetProjectId: project.id,
|
|
targetThreadId: project.threadMeta.threadId,
|
|
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
|
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
|
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
|
});
|
|
|
|
const blockedClaim = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(blockedClaim, null);
|
|
|
|
await applyProjectConflictDecision({
|
|
deviceId: "mac-studio",
|
|
folderKey,
|
|
projectId: project.id,
|
|
decision: "allow_once",
|
|
});
|
|
|
|
const allowedClaim = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(allowedClaim?.taskId, secondTask.taskId);
|
|
|
|
state = await readState();
|
|
policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
|
assert.equal(policy?.allowPolicy, "allow_once");
|
|
|
|
await completeMasterAgentTask({
|
|
taskId: secondTask.taskId,
|
|
deviceId: "mac-studio",
|
|
status: "completed",
|
|
replyBody: "第二轮已完成",
|
|
targetProjectId: project.id,
|
|
targetThreadId: project.threadMeta.threadId,
|
|
});
|
|
|
|
state = await readState();
|
|
policy = state.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
|
assert.ok(policy, "expected scoped policy to remain after consuming allow_once");
|
|
assert.equal(policy?.allowPolicy, "forbid");
|
|
assert.equal(policy?.activeCliExecution, false);
|
|
assert.equal(policy?.conflictState, "blocked");
|
|
});
|