Files
boss/tests/device-execution-conflict.test.ts

441 lines
16 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: "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";
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");
});