485 lines
17 KiB
TypeScript
485 lines
17 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 lets conversation replies run through gui mode when a gui execution channel is available", 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: "krisolo",
|
|
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?.taskId, task.taskId);
|
|
const state = await readState();
|
|
const running = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
|
assert.equal(running?.status, "running");
|
|
});
|
|
|
|
test("claimNextMasterAgentTask keeps conversation replies queued when preferred gui mode has no gui channel", async () => {
|
|
await setup();
|
|
|
|
const project = await getCliProject();
|
|
await updateDevice("mac-studio", {
|
|
preferredExecutionMode: "gui",
|
|
capabilities: {
|
|
gui: {
|
|
connected: false,
|
|
lastSeenAt: "2026-04-06T10:00:00.000Z",
|
|
lastActiveProjectId: "",
|
|
},
|
|
codexAppServer: {
|
|
connected: false,
|
|
lastSeenAt: "2026-04-06T10:00:00.000Z",
|
|
lastActiveProjectId: "",
|
|
},
|
|
},
|
|
});
|
|
const task = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-preferred-gui-no-channel",
|
|
requestText: "继续推进当前线程任务",
|
|
executionPrompt: "请继续推进当前线程任务",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "krisolo",
|
|
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 recentExternalActivityAt = new Date(Date.now() - 60_000).toISOString();
|
|
|
|
const firstTask = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-first",
|
|
requestText: "先推进一轮",
|
|
executionPrompt: "请先推进一轮",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "krisolo",
|
|
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: "krisolo",
|
|
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: recentExternalActivityAt,
|
|
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, recentExternalActivityAt);
|
|
|
|
const secondTask = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-second",
|
|
requestText: "继续推进第二轮",
|
|
executionPrompt: "请继续推进第二轮",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "krisolo",
|
|
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");
|
|
});
|
|
|
|
test("stale blocked policy does not keep queued conversation replies stuck forever", async () => {
|
|
await setup();
|
|
|
|
const project = await getCliProject();
|
|
const folderKey = buildProjectFolderKey(project);
|
|
const state = await readState();
|
|
state.projectExecutionPolicies = [
|
|
{
|
|
deviceId: "mac-studio",
|
|
folderKey,
|
|
projectId: project.id,
|
|
allowPolicy: "forbid",
|
|
conflictState: "blocked",
|
|
recentExternalActivityAt: "2026-04-06T09:30:00.000Z",
|
|
updatedAt: "2026-04-06T09:30:00.000Z",
|
|
},
|
|
];
|
|
await writeState(state);
|
|
|
|
const queuedTask = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-stale-policy",
|
|
requestText: "继续推进这个线程",
|
|
executionPrompt: "请继续推进这个线程",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "krisolo",
|
|
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?.taskId, queuedTask.taskId);
|
|
|
|
const nextState = await readState();
|
|
const policy = nextState.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
|
|
assert.ok(policy, "expected stale scoped policy to remain in state");
|
|
assert.equal(policy?.conflictState, "none");
|
|
assert.equal(policy?.activeCliExecution, true);
|
|
assert.equal(policy?.recentExternalActivityAt, undefined);
|
|
});
|
|
|
|
test("claimNextMasterAgentTask reclaims stale running conversation replies for the same device", async () => {
|
|
await setup();
|
|
|
|
const project = await getCliProject();
|
|
const task = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-stale-running-reply",
|
|
requestText: "请继续推进当前线程",
|
|
executionPrompt: "请继续推进当前线程",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "krisolo",
|
|
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 initialClaim = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(initialClaim?.taskId, task.taskId);
|
|
|
|
const state = await readState();
|
|
const runningTask = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
|
assert.equal(runningTask?.status, "running");
|
|
runningTask!.claimedAt = "2026-04-01T00:00:00.000Z";
|
|
runningTask!.lastProgressAt = "2026-04-01T00:00:00.000Z";
|
|
await writeState(state);
|
|
|
|
const reclaimed = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(reclaimed?.taskId, task.taskId);
|
|
|
|
const nextState = await readState();
|
|
const reclaimedTask = nextState.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
|
assert.equal(reclaimedTask?.status, "running");
|
|
assert.notEqual(reclaimedTask?.claimedAt, "2026-04-01T00:00:00.000Z");
|
|
});
|
|
|
|
test("claimNextMasterAgentTask does not automatically reclaim stale running dispatch_execution tasks", async () => {
|
|
await setup();
|
|
|
|
const project = await getCliProject();
|
|
const task = await queueMasterAgentTask({
|
|
projectId: project.id,
|
|
requestMessageId: "msg-stale-running-dispatch",
|
|
requestText: "请执行修复任务",
|
|
executionPrompt: "请执行修复任务",
|
|
requestedBy: "Boss 超级管理员",
|
|
requestedByAccount: "krisolo",
|
|
deviceId: "mac-studio",
|
|
taskType: "dispatch_execution",
|
|
dispatchExecutionId: "dispatch-exec-stale-1",
|
|
targetProjectId: project.id,
|
|
targetThreadId: project.threadMeta.threadId,
|
|
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
|
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
|
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
|
});
|
|
|
|
const initialClaim = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(initialClaim?.taskId, task.taskId);
|
|
|
|
const state = await readState();
|
|
const runningTask = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
|
assert.equal(runningTask?.status, "running");
|
|
runningTask!.claimedAt = "2026-04-01T00:00:00.000Z";
|
|
await writeState(state);
|
|
|
|
const reclaimed = await claimNextMasterAgentTask("mac-studio");
|
|
assert.equal(reclaimed, null);
|
|
|
|
const nextState = await readState();
|
|
const unchangedTask = nextState.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
|
assert.equal(unchangedTask?.status, "running");
|
|
assert.equal(unchangedTask?.claimedAt, "2026-04-01T00:00:00.000Z");
|
|
});
|