refactor: add execution backend selection

This commit is contained in:
kris
2026-04-03 00:21:19 +08:00
parent a3a4f3e980
commit 8a62e72fd5
11 changed files with 1067 additions and 318 deletions

View File

@@ -610,8 +610,9 @@ export async function selectExecutionBackend(input: {
primary: { provider: string; status: string }; primary: { provider: string; status: string };
backups: Array<{ provider: string; status: string }>; backups: Array<{ provider: string; status: string }>;
}) { }) {
if (input.primary.provider === "master_codex_node" && input.primary.status === "ready") { const primaryBackend = resolveBackendByProvider(input.primary.provider);
return { backendId: "master-codex-node" }; if (input.primary.status === "ready") {
return primaryBackend;
} }
const qwen = input.backups.find((item) => item.provider === "aliyun_qwen_api" && item.status === "ready"); const qwen = input.backups.find((item) => item.provider === "aliyun_qwen_api" && item.status === "ready");
if (qwen) { if (qwen) {
@@ -621,12 +622,20 @@ export async function selectExecutionBackend(input: {
if (openai) { if (openai) {
return { backendId: "openai-api" }; return { backendId: "openai-api" };
} }
return { backendId: "master-codex-node" }; const master = input.backups.find((item) => item.provider === "master_codex_node" && item.status === "ready");
if (master) {
return { backendId: "master-codex-node" };
}
return primaryBackend;
} }
export const selectExecutionBackendForTesting = selectExecutionBackend; export const selectExecutionBackendForTesting = selectExecutionBackend;
``` ```
补充说明:
- selector 的运行时语义以“`ready primary` 优先,否则按 `aliyun_qwen -> openai -> master_codex_node` 顺序回退,最后再回 primary 兜底”为准。
- 如果同一 provider 存在多个账号,只要其中任何一个是 `ready`,该 backend 就视为可选。
`/Users/kris/code/boss/src/lib/boss-master-agent.ts` 中,把 provider fallback 的选择逻辑收进一个单独调用: `/Users/kris/code/boss/src/lib/boss-master-agent.ts` 中,把 provider fallback 的选择逻辑收进一个单独调用:
```ts ```ts

View File

@@ -138,6 +138,11 @@ interface ExecutionBackend {
说明: 说明:
- 这层只负责“选择谁执行”,不负责组装 prompt也不负责审批和业务决策 - 这层只负责“选择谁执行”,不负责组装 prompt也不负责审批和业务决策
- 运行时选择语义应明确为:
- `ready primary` 优先
- 否则按 `aliyun_qwen -> openai -> master_codex_node` 顺序回退
- 如果没有任何 ready backend再回 primary 兜底
- 同一 provider 下如果存在多个账号,只要其中任一账号 `ready`,该 backend 就视为可选
### 3. `SessionRuntime` ### 3. `SessionRuntime`

View File

@@ -5560,6 +5560,39 @@ export async function getMasterAgentTask(taskId: string) {
return state.masterAgentTasks.find((item) => item.taskId === taskId) ?? null; return state.masterAgentTasks.find((item) => item.taskId === taskId) ?? null;
} }
export async function reassignMasterAgentTaskExecution(payload: {
taskId: string;
deviceId: string;
accountId?: string;
accountLabel?: string;
executionPrompt?: string;
}) {
const task = await mutateState((state) => {
const next = state.masterAgentTasks.find((item) => item.taskId === payload.taskId);
if (!next) {
throw new Error("MASTER_AGENT_TASK_NOT_FOUND");
}
if (next.status !== "queued") {
return { ...next };
}
next.deviceId = payload.deviceId;
next.accountId = payload.accountId;
next.accountLabel = payload.accountLabel;
if (payload.executionPrompt?.trim()) {
next.executionPrompt = payload.executionPrompt.trim();
}
return { ...next };
});
publishBossEvent("master_agent.task.updated", {
taskId: task.taskId,
deviceId: task.deviceId,
status: task.status,
});
return task;
}
export async function claimNextMasterAgentTask(deviceId: string) { export async function claimNextMasterAgentTask(deviceId: string) {
let attachmentProjectId: string | undefined; let attachmentProjectId: string | undefined;
let dispatchExecutionProjectId: string | undefined; let dispatchExecutionProjectId: string | undefined;

View File

@@ -14,13 +14,22 @@ import {
getMasterAgentTask, getMasterAgentTask,
queueMasterAgentTask, queueMasterAgentTask,
readState, readState,
reassignMasterAgentTaskExecution,
isDispatchableThreadProject, isDispatchableThreadProject,
touchUserMasterMemories, touchUserMasterMemories,
updateAttachmentAnalysisResult, updateAttachmentAnalysisResult,
updateAiAccountHealth, updateAiAccountHealth,
} from "@/lib/boss-data"; } from "@/lib/boss-data";
import type { AiProvider, DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data"; import type {
AiAccount,
AiProvider,
DispatchPlanTarget,
Project,
ProjectAgentControls,
ReasoningEffort,
} from "@/lib/boss-data";
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
import { listExecutionBackendChoices, selectExecutionBackend } from "@/lib/execution/backend-selector";
import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver"; import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver";
import type { RelevantMemory } from "@/lib/execution/memory-resolver"; import type { RelevantMemory } from "@/lib/execution/memory-resolver";
import { buildExecutionPrompt } from "@/lib/execution/prompt-assembler"; import { buildExecutionPrompt } from "@/lib/execution/prompt-assembler";
@@ -363,38 +372,140 @@ function normalizeApiProviderFetchFailure(provider: ApiCompatibleProvider, error
return normalizeApiProviderError(provider, String(error)); return normalizeApiProviderError(provider, String(error));
} }
function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") { function isUsableApiAccount(account: AiAccount, provider: ApiCompatibleProvider) {
switch (role) { return (
case "primary": account.enabled &&
return 0; account.provider === provider &&
case "backup": (account.status === "ready" || account.status === "degraded") &&
return 1; Boolean(account.apiKey?.trim())
case "api_fallback": );
return 2;
default:
return 9;
}
} }
async function findFallbackApiAccount(excludedAccountId?: string) { function isUsableMasterNodeAccount(account: AiAccount) {
const state = await readState(); return (
return [...state.aiAccounts] account.enabled &&
.filter( account.provider === "master_codex_node" &&
(account) => account.status === "ready" &&
account.accountId !== excludedAccountId && Boolean(account.nodeId?.trim())
account.enabled && );
isApiCompatibleProvider(account.provider) && }
Boolean(account.apiKey?.trim()),
) function isOnlineMasterNodeAccount(
.sort((left, right) => { state: Awaited<ReturnType<typeof readState>>,
const roleDelta = fallbackAiRolePriority(left.role) - fallbackAiRolePriority(right.role); account: AiAccount,
if (roleDelta !== 0) return roleDelta; ) {
return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); if (!isUsableMasterNodeAccount(account)) {
})[0]; return false;
}
const deviceId = account.nodeId?.trim();
if (!deviceId) {
return false;
}
const device = state.devices.find((item) => item.id === deviceId);
return Boolean(device && device.status === "online");
}
function sortSelectableAccounts(left: AiAccount, right: AiAccount) {
if (left.isActive !== right.isActive) {
return left.isActive ? -1 : 1;
}
return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "");
}
function sortApiSelectableAccounts(left: AiAccount, right: AiAccount) {
if (left.status !== right.status) {
return left.status === "ready" ? -1 : 1;
}
return sortSelectableAccounts(left, right);
}
async function resolveAccountForSelectedBackend(
selectedBackendProvider: AiProvider,
runtimeAccount: AiAccount,
) {
if (selectedBackendProvider === "master_codex_node") {
const state = await readState();
if (isOnlineMasterNodeAccount(state, runtimeAccount)) {
return runtimeAccount;
}
return state.aiAccounts
.filter((account) => isOnlineMasterNodeAccount(state, account))
.sort(sortSelectableAccounts)[0];
}
if (selectedBackendProvider === "openai_api" || selectedBackendProvider === "aliyun_qwen_api") {
const state = await readState();
const candidates = [
...(isUsableApiAccount(runtimeAccount, selectedBackendProvider) ? [runtimeAccount] : []),
...state.aiAccounts.filter((account): account is AiAccount =>
account.accountId !== runtimeAccount.accountId && isUsableApiAccount(account, selectedBackendProvider),
),
];
return candidates.sort(sortApiSelectableAccounts)[0];
}
return null;
}
interface ApiExecutionCandidate {
provider: ApiCompatibleProvider;
account: AiAccount;
deviceId: string;
model: string;
}
async function buildApiExecutionCandidates(params: {
backendChoices: Array<{ provider: AiProvider }>;
runtimeAccount: AiAccount;
agentControls?: ProjectAgentControls | null;
}) {
const candidates: ApiExecutionCandidate[] = [];
const seenAccountIds = new Set<string>();
for (const backend of params.backendChoices) {
if (!isApiCompatibleProvider(backend.provider)) {
continue;
}
const account = await resolveAccountForSelectedBackend(backend.provider, params.runtimeAccount);
if (!account || !isApiCompatibleProvider(account.provider) || !account.apiKey?.trim()) {
continue;
}
if (seenAccountIds.has(account.accountId)) {
continue;
}
seenAccountIds.add(account.accountId);
candidates.push({
provider: account.provider,
account,
deviceId: account.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID,
model:
params.agentControls?.modelOverride ||
account.model ||
apiProviderConfig(account.provider).defaultModel,
});
}
return candidates;
}
async function resolveMasterNodeExecutionCandidate(params: {
backendChoices: Array<{ backendId: string; provider: AiProvider }>;
runtimeAccount: AiAccount;
}) {
const wantsMasterNode = params.backendChoices.some((backend) => backend.backendId === "master-codex-node");
if (!wantsMasterNode) {
return null;
}
const account = await resolveAccountForSelectedBackend("master_codex_node", params.runtimeAccount);
return account && account.provider === "master_codex_node" ? account : null;
} }
async function replyViaOpenAiAccount(params: { async function replyViaOpenAiAccount(params: {
account: Awaited<ReturnType<typeof findFallbackApiAccount>>; account: AiAccount;
requestText: string; requestText: string;
currentSessionExpiresAt?: string; currentSessionExpiresAt?: string;
senderLabel: string; senderLabel: string;
@@ -431,6 +542,10 @@ async function replyViaOpenAiAccount(params: {
status: "ready", status: "ready",
lastValidatedAt: new Date().toISOString(), lastValidatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(),
activate: !params.account.isActive,
switchReason: params.account.isActive
? params.account.switchReason
: `主 Agent 回复时自动切换到 ${params.account.label}`,
}); });
return { return {
@@ -554,80 +669,146 @@ function buildMasterOpenAiReplyPrompt(
} }
async function queueAndStartOpenAiMasterAgentReply(params: { async function queueAndStartOpenAiMasterAgentReply(params: {
provider: ApiCompatibleProvider; candidates: ApiExecutionCandidate[];
taskId: string; taskId: string;
deviceId: string;
requestText: string; requestText: string;
currentSessionExpiresAt?: string; currentSessionExpiresAt?: string;
apiKey: string;
model: string;
reasoningEffort: ReasoningEffort; reasoningEffort: ReasoningEffort;
agentControls?: ProjectAgentControls | null; agentControls?: ProjectAgentControls | null;
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>; promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>; userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
projectMemories?: RelevantMemory[]; projectMemories?: RelevantMemory[];
userMemories?: RelevantMemory[]; userMemories?: RelevantMemory[];
masterFallback?: {
account: AiAccount;
executionPrompt: string;
} | null;
}) { }) {
const completeTaskSafely = async (payload: Parameters<typeof completeMasterAgentTask>[0]) => {
try {
await completeMasterAgentTask(payload);
} catch (error) {
if (error instanceof Error && error.message === "MASTER_AGENT_TASK_NOT_FOUND") {
return;
}
throw error;
}
};
const timer = setTimeout(() => { const timer = setTimeout(() => {
void (async () => { void (async () => {
const task = await getMasterAgentTask(params.taskId); let lastErrorMessage = "主 Agent 当前调用模型失败。";
if (!task || task.status !== "queued") {
for (const candidate of params.candidates) {
const task = await getMasterAgentTask(params.taskId);
if (!task || task.status !== "queued") {
return;
}
if (task.accountId !== candidate.account.accountId || task.deviceId !== candidate.deviceId) {
await reassignMasterAgentTaskExecution({
taskId: params.taskId,
deviceId: candidate.deviceId,
accountId: candidate.account.accountId,
accountLabel: candidate.account.label,
});
}
try {
const generated = await generateApiProviderReply({
provider: candidate.provider,
apiKey: candidate.account.apiKey ?? "",
model: candidate.model,
reasoningEffort: params.reasoningEffort,
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
agentControls: params.agentControls,
promptPolicy: params.promptPolicy,
userPrompt: params.userPrompt,
projectMemories: params.projectMemories,
userMemories: params.userMemories,
});
await completeTaskSafely({
taskId: params.taskId,
deviceId: candidate.deviceId,
status: "completed",
replyBody: generated.content,
requestId: generated.requestId,
});
await updateAiAccountHealth({
accountId: candidate.account.accountId,
status: "ready",
lastValidatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
activate: !candidate.account.isActive,
switchReason: candidate.account.isActive
? candidate.account.switchReason
: `主 Agent 回复时自动切换到 ${candidate.account.label}`,
});
return;
} catch (error) {
if (error instanceof Error && error.message === "MASTER_AGENT_TASK_NOT_FOUND") {
return;
}
lastErrorMessage = error instanceof Error ? error.message : "主 Agent 当前调用模型失败。";
await updateAiAccountHealth({
accountId: candidate.account.accountId,
status: "degraded",
lastError: lastErrorMessage,
lastValidatedAt: new Date().toISOString(),
});
}
}
if (params.masterFallback) {
const fallbackTask = await getMasterAgentTask(params.taskId);
if (!fallbackTask || fallbackTask.status !== "queued") {
return;
}
await reassignMasterAgentTaskExecution({
taskId: params.taskId,
deviceId: params.masterFallback.account.nodeId || "mac-studio",
accountId: params.masterFallback.account.accountId,
accountLabel: params.masterFallback.account.label,
executionPrompt: params.masterFallback.executionPrompt,
});
return; return;
} }
try { await completeTaskSafely({
const generated = await generateApiProviderReply({ taskId: params.taskId,
provider: params.provider, deviceId: params.candidates[0]?.deviceId ?? OPENAI_MASTER_AGENT_DEVICE_ID,
apiKey: params.apiKey, status: "failed",
model: params.model, errorMessage: lastErrorMessage,
reasoningEffort: params.reasoningEffort, });
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
agentControls: params.agentControls,
promptPolicy: params.promptPolicy,
userPrompt: params.userPrompt,
projectMemories: params.projectMemories,
userMemories: params.userMemories,
});
await completeMasterAgentTask({
taskId: params.taskId,
deviceId: params.deviceId,
status: "completed",
replyBody: generated.content,
requestId: generated.requestId,
});
} catch (error) {
await completeMasterAgentTask({
taskId: params.taskId,
deviceId: params.deviceId,
status: "failed",
errorMessage: error instanceof Error ? error.message : "主 Agent 当前调用模型失败。",
});
}
})(); })();
}, 0); }, 0);
timer.unref?.(); timer.unref?.();
} }
async function enqueueOpenAiMasterAgentReply(params: { async function enqueueOpenAiMasterAgentReply(params: {
provider: ApiCompatibleProvider; candidates: ApiExecutionCandidate[];
accountId: string;
accountLabel: string;
requestMessageId?: string; requestMessageId?: string;
requestText: string; requestText: string;
requestedBy: string; requestedBy: string;
requestedByAccount: string; requestedByAccount: string;
currentSessionExpiresAt?: string; currentSessionExpiresAt?: string;
apiKey: string;
model: string;
reasoningEffort: ReasoningEffort; reasoningEffort: ReasoningEffort;
agentControls?: ProjectAgentControls | null; agentControls?: ProjectAgentControls | null;
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>; promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>; userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
projectMemories?: RelevantMemory[]; projectMemories?: RelevantMemory[];
userMemories?: RelevantMemory[]; userMemories?: RelevantMemory[];
masterFallback?: {
account: AiAccount;
executionPrompt: string;
} | null;
}) { }) {
const primaryCandidate = params.candidates[0];
if (!primaryCandidate) {
throw new Error("MASTER_AGENT_API_BACKEND_NOT_AVAILABLE");
}
const state = await readState(); const state = await readState();
const task = await queueMasterAgentTask({ const task = await queueMasterAgentTask({
requestMessageId: params.requestMessageId ?? "master-agent-manual", requestMessageId: params.requestMessageId ?? "master-agent-manual",
@@ -644,29 +825,27 @@ async function enqueueOpenAiMasterAgentReply(params: {
), ),
requestedBy: params.requestedBy, requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount, requestedByAccount: params.requestedByAccount,
deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID, deviceId: primaryCandidate.deviceId,
accountId: params.accountId, accountId: primaryCandidate.account.accountId,
accountLabel: params.accountLabel, accountLabel: primaryCandidate.account.label,
}); });
void queueAndStartOpenAiMasterAgentReply({ void queueAndStartOpenAiMasterAgentReply({
provider: params.provider, candidates: params.candidates,
taskId: task.taskId, taskId: task.taskId,
deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID,
requestText: params.requestText, requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt, currentSessionExpiresAt: params.currentSessionExpiresAt,
apiKey: params.apiKey,
model: params.model,
reasoningEffort: params.reasoningEffort, reasoningEffort: params.reasoningEffort,
agentControls: params.agentControls, agentControls: params.agentControls,
promptPolicy: params.promptPolicy, promptPolicy: params.promptPolicy,
userPrompt: params.userPrompt, userPrompt: params.userPrompt,
projectMemories: params.projectMemories, projectMemories: params.projectMemories,
userMemories: params.userMemories, userMemories: params.userMemories,
masterFallback: params.masterFallback,
}); });
const queuedReply: QueuedMasterAgentReplyEnvelope = { const queuedReply: QueuedMasterAgentReplyEnvelope = {
ok: true as const, ok: true as const,
accountId: params.accountId, accountId: primaryCandidate.account.accountId,
taskId: task.taskId, taskId: task.taskId,
masterReplyState: "queued" as const, masterReplyState: "queued" as const,
task: { task: {
@@ -1390,82 +1569,98 @@ export async function replyToMasterAgentUserMessage(params: {
params.requestedByAccount, params.requestedByAccount,
params.requestText, params.requestText,
); );
const state = await readState();
const primaryDeviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
const primaryDevice = state.devices.find((device) => device.id === primaryDeviceId);
const primaryBackendStatus =
runtime.account.provider === "master_codex_node" && (!primaryDevice || primaryDevice.status !== "online")
? "degraded"
: runtime.account.status;
const backendSelectionInput = {
primary: {
provider: runtime.account.provider,
status: primaryBackendStatus,
},
backups: state.aiAccounts
.filter((account) => account.accountId !== runtime.account.accountId)
.map((account) => ({
provider: account.provider,
status: account.status,
})),
};
const selectedBackend = await selectExecutionBackend(backendSelectionInput);
const backendChoices = listExecutionBackendChoices(backendSelectionInput);
const agentControls = executionConfig.agentControls; const agentControls = executionConfig.agentControls;
const masterExecutionPrompt = buildMasterCodexNodePrompt(
state,
params.requestText,
params.currentSessionExpiresAt,
agentControls,
executionConfig.promptPolicy,
executionConfig.userPrompt,
executionConfig.projectMemories,
executionConfig.userMemories,
);
const selectedMasterAccount = await resolveMasterNodeExecutionCandidate({
backendChoices,
runtimeAccount: runtime.account,
});
const apiExecutionCandidates = await buildApiExecutionCandidates({
backendChoices,
runtimeAccount: runtime.account,
agentControls,
});
const hasMasterFallback = backendChoices.some((backend) => backend.backendId === "master-codex-node");
if (params.mode === "enqueue") { const runMasterNodeExecution = async () => {
if (runtime.account.provider === "master_codex_node") { if (!selectedMasterAccount) {
const state = await readState(); await appendMasterAgentSystemReply(
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio"; [
const boundDevice = state.devices.find((device) => device.id === deviceId); `当前主控身份是 ${runtime.summary.roleLabel},目标后端是 Master Codex Node但当前没有可用的 master 节点账号。`,
const boundNodeLabel = "请先把可用的 Master Codex Node 重新接回,再重试。",
runtime.account.nodeLabel?.trim() || ].join(""),
boundDevice?.name || `主 Agent · ${runtime.summary.roleLabel}`,
state.user.boundCodexNodeLabel || );
deviceId; return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
}
if (!boundDevice || boundDevice.status !== "online") { const deviceId = selectedMasterAccount.nodeId || state.user.boundDeviceId || "mac-studio";
await updateAiAccountHealth({ const boundDevice = state.devices.find((device) => device.id === deviceId);
accountId: runtime.account.accountId, const boundNodeLabel =
status: "degraded", selectedMasterAccount.nodeLabel?.trim() ||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE", boundDevice?.name ||
lastValidatedAt: new Date().toISOString(), state.user.boundCodexNodeLabel ||
}); deviceId;
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId); if (!boundDevice || boundDevice.status !== "online") {
if (fallbackAccount?.apiKey?.trim() && isApiCompatibleProvider(fallbackAccount.provider)) { await updateAiAccountHealth({
return enqueueOpenAiMasterAgentReply({ accountId: selectedMasterAccount.accountId,
provider: fallbackAccount.provider, status: "degraded",
accountId: fallbackAccount.accountId, lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role), lastValidatedAt: new Date().toISOString(),
requestMessageId: params.requestMessageId,
requestText: params.requestText,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
currentSessionExpiresAt: params.currentSessionExpiresAt,
apiKey: fallbackAccount.apiKey,
model:
agentControls?.modelOverride ||
fallbackAccount.model ||
apiProviderConfig(fallbackAccount.provider).defaultModel,
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
});
}
await appendMasterAgentSystemReply(
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus并确保 local-agent 在线后再重试。`,
`主 Agent · ${runtime.summary.roleLabel}`,
);
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
}
const task = await queueMasterAgentTask({
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
executionPrompt: buildMasterCodexNodePrompt(
state,
params.requestText,
params.currentSessionExpiresAt,
agentControls,
executionConfig.promptPolicy,
executionConfig.userPrompt,
executionConfig.projectMemories,
executionConfig.userMemories,
),
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
accountId: runtime.account.accountId,
accountLabel: runtime.account.label || runtime.summary.roleLabel,
}); });
await appendMasterAgentSystemReply(
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus并确保 local-agent 在线后再重试。`,
`主 Agent · ${selectedMasterAccount.label || runtime.summary.roleLabel}`,
);
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
}
const task = await queueMasterAgentTask({
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
executionPrompt: masterExecutionPrompt,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
accountId: selectedMasterAccount.accountId,
accountLabel: selectedMasterAccount.label || runtime.summary.roleLabel,
});
if (params.mode === "enqueue") {
const queuedReply: QueuedMasterAgentReplyEnvelope = { const queuedReply: QueuedMasterAgentReplyEnvelope = {
ok: true as const, ok: true as const,
accountId: runtime.account.accountId, accountId: selectedMasterAccount.accountId,
taskId: task.taskId, taskId: task.taskId,
masterReplyState: "queued" as const, masterReplyState: "queued" as const,
task: { task: {
@@ -1477,117 +1672,16 @@ export async function replyToMasterAgentUserMessage(params: {
return queuedReply; return queuedReply;
} }
if (isApiCompatibleProvider(runtime.account.provider) && runtime.account.apiKey?.trim()) {
return enqueueOpenAiMasterAgentReply({
provider: runtime.account.provider,
accountId: runtime.account.accountId,
accountLabel: runtime.account.label || runtime.summary.roleLabel,
requestMessageId: params.requestMessageId,
requestText: params.requestText,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
currentSessionExpiresAt: params.currentSessionExpiresAt,
apiKey: runtime.account.apiKey,
model: executionConfig.model,
reasoningEffort: executionConfig.reasoningEffort,
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
});
}
}
if (runtime.account.provider === "master_codex_node") {
const state = await readState();
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
const boundDevice = state.devices.find((device) => device.id === deviceId);
const boundNodeLabel =
runtime.account.nodeLabel?.trim() ||
boundDevice?.name ||
state.user.boundCodexNodeLabel ||
deviceId;
if (!boundDevice || boundDevice.status !== "online") {
await updateAiAccountHealth({
accountId: runtime.account.accountId,
status: "degraded",
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
lastValidatedAt: new Date().toISOString(),
});
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId);
if (fallbackAccount) {
try {
return await replyViaOpenAiAccount({
account: fallbackAccount,
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
});
} catch {
// Fall through to the original offline guidance when the fallback API account cannot respond.
}
}
await appendMasterAgentSystemReply(
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus并确保 local-agent 在线后再重试。`,
`主 Agent · ${runtime.summary.roleLabel}`,
);
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
}
const task = await queueMasterAgentTask({
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
executionPrompt: buildMasterCodexNodePrompt(
state,
params.requestText,
params.currentSessionExpiresAt,
agentControls,
executionConfig.promptPolicy,
executionConfig.userPrompt,
executionConfig.projectMemories,
executionConfig.userMemories,
),
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
accountId: runtime.account.accountId,
accountLabel: runtime.summary.roleLabel,
});
const completedTask = await waitForMasterAgentTaskCompletion(task.taskId); const completedTask = await waitForMasterAgentTaskCompletion(task.taskId);
if (completedTask?.status === "completed") { if (completedTask?.status === "completed") {
return { return {
ok: true as const, ok: true as const,
accountId: runtime.account.accountId, accountId: selectedMasterAccount.accountId,
taskId: task.taskId, taskId: task.taskId,
requestId: completedTask.requestId, requestId: completedTask.requestId,
}; };
} }
if (completedTask?.status === "failed") { if (completedTask?.status === "failed") {
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId);
if (fallbackAccount) {
try {
return await replyViaOpenAiAccount({
account: fallbackAccount,
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
});
} catch {
// Preserve the original execution failure below if the fallback account also fails.
}
}
return { return {
ok: false as const, ok: false as const,
reason: "MASTER_NODE_EXEC_FAILED", reason: "MASTER_NODE_EXEC_FAILED",
@@ -1601,9 +1695,86 @@ export async function replyToMasterAgentUserMessage(params: {
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 Master Codex Node。`, `当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 Master Codex Node。`,
"如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。", "如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。",
].join(""), ].join(""),
`主 Agent · ${runtime.summary.roleLabel}`, `主 Agent · ${selectedMasterAccount.label || runtime.summary.roleLabel}`,
); );
return { ok: true as const, accountId: runtime.account.accountId, taskId: task.taskId }; return { ok: true as const, accountId: selectedMasterAccount.accountId, taskId: task.taskId };
};
if (params.mode === "enqueue") {
if (selectedBackend.backendId === "master-codex-node") {
return runMasterNodeExecution();
}
if (apiExecutionCandidates.length > 0) {
return enqueueOpenAiMasterAgentReply({
candidates: apiExecutionCandidates,
requestMessageId: params.requestMessageId,
requestText: params.requestText,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
currentSessionExpiresAt: params.currentSessionExpiresAt,
reasoningEffort: executionConfig.reasoningEffort,
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
masterFallback: hasMasterFallback && selectedMasterAccount
? {
account: selectedMasterAccount,
executionPrompt: masterExecutionPrompt,
}
: null,
});
}
}
if (selectedBackend.backendId === "master-codex-node") {
return runMasterNodeExecution();
}
let lastApiFailureMessage: string | null = null;
let lastFailedAccount: AiAccount | null = null;
for (const candidate of apiExecutionCandidates) {
try {
return await replyViaOpenAiAccount({
account: candidate.account,
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
senderLabel: `主 Agent · ${candidate.account.label || aiRoleLabel(candidate.account.role)}`,
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
});
} catch (error) {
lastApiFailureMessage = error instanceof Error ? error.message : "主 Agent 当前调用模型失败。";
lastFailedAccount = candidate.account;
if (!runtime.isEnvironmentFallback) {
await updateAiAccountHealth({
accountId: candidate.account.accountId,
status: "degraded",
lastError: lastApiFailureMessage,
lastValidatedAt: new Date().toISOString(),
});
}
}
}
if (hasMasterFallback && selectedMasterAccount) {
return runMasterNodeExecution();
}
if (lastApiFailureMessage) {
await appendMasterAgentSystemReply(
[
`我已经收到你的消息,但当前 AI 账号调用失败:${lastApiFailureMessage}`,
"请到“我的 > AI 账号”检查 API Key、模型名或切换到其他 AI 账号后重试。",
].join(""),
`主 Agent · ${lastFailedAccount?.label || runtime.summary.roleLabel}`,
);
return { ok: false as const, reason: "MODEL_CALL_FAILED", message: lastApiFailureMessage };
} }
if (!isApiCompatibleProvider(runtime.account.provider) || !runtime.account.apiKey?.trim()) { if (!isApiCompatibleProvider(runtime.account.provider) || !runtime.account.apiKey?.trim()) {
@@ -1618,61 +1789,5 @@ export async function replyToMasterAgentUserMessage(params: {
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" }; return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
} }
try { return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
const generated = await generateApiProviderReply({
provider: runtime.account.provider,
apiKey: runtime.account.apiKey,
model: executionConfig.model,
reasoningEffort: executionConfig.reasoningEffort,
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
agentControls,
promptPolicy: executionConfig.promptPolicy,
userPrompt: executionConfig.userPrompt,
projectMemories: executionConfig.projectMemories,
userMemories: executionConfig.userMemories,
});
await appendMasterAgentSystemReply(
generated.content,
`主 Agent · ${runtime.summary.roleLabel}`,
);
if (!runtime.isEnvironmentFallback) {
await updateAiAccountHealth({
accountId: runtime.account.accountId,
status: "ready",
lastValidatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
activate: !runtime.account.isActive,
switchReason: runtime.account.isActive
? runtime.account.switchReason
: `主 Agent 回复时自动切换到 ${runtime.account.label}`,
});
}
return {
ok: true as const,
accountId: runtime.account.accountId,
requestId: generated.requestId,
};
} catch (error) {
const message = error instanceof Error ? error.message : "主 Agent 当前调用模型失败。";
if (!runtime.isEnvironmentFallback) {
await updateAiAccountHealth({
accountId: runtime.account.accountId,
status: "degraded",
lastError: message,
lastValidatedAt: new Date().toISOString(),
});
}
await appendMasterAgentSystemReply(
[
`我已经收到你的消息,但当前 AI 账号调用失败:${message}`,
"请到“我的 > AI 账号”检查 API Key、模型名或切换到其他 AI 账号后重试。",
].join(""),
`主 Agent · ${runtime.summary.roleLabel}`,
);
return { ok: false as const, reason: "MODEL_CALL_FAILED", message };
}
} }

View File

@@ -0,0 +1,97 @@
import type { AiAccountStatus, AiProvider } from "@/lib/boss-data";
import {
ALIYUN_QWEN_BACKEND,
isReadyAliyunQwenBackend,
} from "@/lib/execution/backends/aliyun-qwen-backend";
import {
MASTER_CODEX_NODE_BACKEND,
isReadyMasterCodexNodeBackend,
} from "@/lib/execution/backends/master-codex-node-backend";
import { OPENAI_BACKEND, isReadyOpenAiBackend } from "@/lib/execution/backends/openai-backend";
export interface ExecutionBackendSelectionInput {
primary: {
provider: AiProvider;
status: AiAccountStatus;
};
backups: Array<{
provider: AiProvider;
status: AiAccountStatus;
}>;
}
export type ExecutionBackendChoice =
| typeof MASTER_CODEX_NODE_BACKEND
| typeof OPENAI_BACKEND
| typeof ALIYUN_QWEN_BACKEND;
function resolveBackendByProvider(provider: AiProvider): ExecutionBackendChoice {
switch (provider) {
case "master_codex_node":
return MASTER_CODEX_NODE_BACKEND;
case "openai_api":
return OPENAI_BACKEND;
case "aliyun_qwen_api":
return ALIYUN_QWEN_BACKEND;
default:
return MASTER_CODEX_NODE_BACKEND;
}
}
function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendSelectionInput) {
const candidates = [
...(input.primary.provider === choice.provider ? [input.primary] : []),
...input.backups.filter((item) => item.provider === choice.provider),
];
return candidates.some((candidate) => {
switch (choice.backendId) {
case "aliyun-qwen":
return isReadyAliyunQwenBackend(candidate);
case "openai-api":
return isReadyOpenAiBackend(candidate);
case "master-codex-node":
return isReadyMasterCodexNodeBackend(candidate);
default:
return false;
}
});
}
export async function selectExecutionBackend(
input: ExecutionBackendSelectionInput,
): Promise<ExecutionBackendChoice> {
return listExecutionBackendChoices(input)[0] ?? resolveBackendByProvider(input.primary.provider);
}
export function listExecutionBackendChoices(
input: ExecutionBackendSelectionInput,
): ExecutionBackendChoice[] {
const primaryBackend = resolveBackendByProvider(input.primary.provider);
const ordered: ExecutionBackendChoice[] = [];
const seen = new Set<string>();
const pushBackend = (backend: ExecutionBackendChoice) => {
if (seen.has(backend.backendId)) {
return;
}
ordered.push(backend);
seen.add(backend.backendId);
};
if (input.primary.status === "ready") {
pushBackend(primaryBackend);
}
const orderedBackends: ExecutionBackendChoice[] = [ALIYUN_QWEN_BACKEND, OPENAI_BACKEND, MASTER_CODEX_NODE_BACKEND];
for (const backend of orderedBackends) {
if (isReadyBackend(backend, input)) {
pushBackend(backend);
}
}
pushBackend(primaryBackend);
return ordered;
}
export const selectExecutionBackendForTesting = selectExecutionBackend;

View File

@@ -0,0 +1,22 @@
import type { AiAccountStatus, AiProvider } from "@/lib/boss-data";
export interface ExecutionBackendDescriptor {
backendId: string;
provider: AiProvider;
label: string;
mode: "local" | "api";
}
export const ALIYUN_QWEN_BACKEND = {
backendId: "aliyun-qwen",
provider: "aliyun_qwen_api",
label: "阿里百炼 Qwen",
mode: "api",
} as const satisfies ExecutionBackendDescriptor;
export function isReadyAliyunQwenBackend(input: {
provider: AiProvider;
status: AiAccountStatus;
}) {
return input.provider === ALIYUN_QWEN_BACKEND.provider && input.status === "ready";
}

View File

@@ -0,0 +1,22 @@
import type { AiAccountStatus, AiProvider } from "@/lib/boss-data";
export interface ExecutionBackendDescriptor {
backendId: string;
provider: AiProvider;
label: string;
mode: "local" | "api";
}
export const MASTER_CODEX_NODE_BACKEND = {
backendId: "master-codex-node",
provider: "master_codex_node",
label: "Master Codex Node",
mode: "local",
} as const satisfies ExecutionBackendDescriptor;
export function isReadyMasterCodexNodeBackend(input: {
provider: AiProvider;
status: AiAccountStatus;
}) {
return input.provider === MASTER_CODEX_NODE_BACKEND.provider && input.status === "ready";
}

View File

@@ -0,0 +1,19 @@
import type { AiAccountStatus, AiProvider } from "@/lib/boss-data";
export interface ExecutionBackendDescriptor {
backendId: string;
provider: AiProvider;
label: string;
mode: "local" | "api";
}
export const OPENAI_BACKEND = {
backendId: "openai-api",
provider: "openai_api",
label: "OpenAI API",
mode: "api",
} as const satisfies ExecutionBackendDescriptor;
export function isReadyOpenAiBackend(input: { provider: AiProvider; status: AiAccountStatus }) {
return input.provider === OPENAI_BACKEND.provider && input.status === "ready";
}

View File

@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectExecutionBackendForTesting } from "@/lib/execution/backend-selector";
test("selectExecutionBackendForTesting prefers the ready primary master codex node", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [
{ provider: "aliyun_qwen_api", status: "ready" },
{ provider: "openai_api", status: "ready" },
],
});
assert.equal(backend.backendId, "master-codex-node");
});
test("selectExecutionBackendForTesting falls back to ready aliyun qwen before openai", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "degraded" },
backups: [
{ provider: "openai_api", status: "ready" },
{ provider: "aliyun_qwen_api", status: "ready" },
],
});
assert.equal(backend.backendId, "aliyun-qwen");
});
test("selectExecutionBackendForTesting falls back to ready openai when aliyun qwen is unavailable", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "degraded" },
backups: [
{ provider: "openai_api", status: "ready" },
{ provider: "aliyun_qwen_api", status: "disabled" },
],
});
assert.equal(backend.backendId, "openai-api");
});
test("selectExecutionBackendForTesting uses fixed backend order when an API primary is not ready", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "openai_api", status: "degraded" },
backups: [
{ provider: "master_codex_node", status: "ready" },
{ provider: "aliyun_qwen_api", status: "ready" },
],
});
assert.equal(backend.backendId, "aliyun-qwen");
});
test("selectExecutionBackendForTesting does not let an earlier disabled backup hide a later ready account", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "degraded" },
backups: [
{ provider: "openai_api", status: "disabled" },
{ provider: "openai_api", status: "ready" },
],
});
assert.equal(backend.backendId, "openai-api");
});
test("selectExecutionBackendForTesting falls back to master node last when higher-priority API backends are unavailable", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "openai_api", status: "degraded" },
backups: [
{ provider: "aliyun_qwen_api", status: "disabled" },
{ provider: "master_codex_node", status: "ready" },
],
});
assert.equal(backend.backendId, "master-codex-node");
});

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
let runtimeRoot = ""; let runtimeRoot = "";
@@ -71,9 +71,13 @@ test.after(async () => {
} }
}); });
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => { test.beforeEach(async () => {
await setup(); await setup();
await rm(runtimeRoot, { recursive: true, force: true });
await mkdir(runtimeRoot, { recursive: true });
});
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
await saveAiAccount({ await saveAiAccount({
accountId: "openai-master-agent-queue", accountId: "openai-master-agent-queue",
label: "API 容灾", label: "API 容灾",
@@ -120,10 +124,11 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
ok: boolean; ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null; task?: { taskId: string; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed"; masterReplyState?: "queued" | "running" | "completed";
masterReply?: unknown; masterReply?: { accountId?: string } | null;
}; };
assert.equal(payload.ok, true); assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "openai-master-agent-queue");
assert.equal(payload.masterReplyState, "queued"); assert.equal(payload.masterReplyState, "queued");
assert.ok(payload.task, "expected master-agent message to return a task envelope"); assert.ok(payload.task, "expected master-agent message to return a task envelope");
assert.equal(payload.task?.taskType, "conversation_reply"); assert.equal(payload.task?.taskType, "conversation_reply");
@@ -161,8 +166,6 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
}); });
test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => { test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => {
await setup();
await saveAiAccount({ await saveAiAccount({
accountId: "master-codex-primary-offline", accountId: "master-codex-primary-offline",
label: "主 GPT", label: "主 GPT",
@@ -214,8 +217,10 @@ test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队
ok: boolean; ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null; task?: { taskId: string; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed"; masterReplyState?: "queued" | "running" | "completed";
masterReply?: { accountId?: string } | null;
}; };
assert.equal(payload.ok, true); assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "openai-backup-queue");
assert.equal(payload.masterReplyState, "queued"); assert.equal(payload.masterReplyState, "queued");
assert.equal(payload.task?.taskType, "conversation_reply"); assert.equal(payload.task?.taskType, "conversation_reply");
@@ -234,3 +239,150 @@ test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}); });
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
await saveAiAccount({
accountId: "master-codex-primary-offline",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "离线 Master Codex Node",
nodeId: "offline-node",
nodeLabel: "离线节点",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "离线主节点",
});
await saveAiAccount({
accountId: "master-codex-backup-online",
label: "备用主节点",
role: "backup",
provider: "master_codex_node",
displayName: "在线备用 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: false,
loginStatusNote: "在线备用主节点",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "请走备用主节点队列",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed";
masterReply?: { accountId?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "master-codex-backup-online");
assert.equal(payload.masterReplyState, "queued");
assert.equal(payload.task?.taskType, "conversation_reply");
assert.equal(payload.task?.status, "queued");
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.ok(task, "expected queued master-agent task");
assert.equal(task?.accountId, "master-codex-backup-online");
assert.equal(task?.deviceId, "mac-studio");
});
test("master-agent enqueue 会在首个 API 候选失败后切到下一条备用链并重写任务账号", async () => {
await saveAiAccount({
accountId: "openai-primary-queue",
label: "OpenAI 主控",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4",
apiKey: "sk-openai-primary-queue",
enabled: true,
setActive: true,
loginStatusNote: "OpenAI 主控",
});
await saveAiAccount({
accountId: "aliyun-qwen-backup-queue",
label: "阿里备用",
role: "backup",
provider: "aliyun_qwen_api",
displayName: "阿里百炼备用账号",
model: "qwen3.5-plus",
apiKey: "sk-aliyun-backup-queue",
enabled: true,
setActive: false,
loginStatusNote: "阿里备用账号",
});
const fetchCalls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
fetchCalls.push(String(input));
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
return new Response(JSON.stringify({ error: { message: "openai queue failure" } }), {
status: 500,
headers: { "content-type": "application/json" },
});
}
if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") {
return new Response(JSON.stringify({ output_text: "后台队列已切到阿里备用。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-agent-queue-fallback-chain",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const response = await POST(
await createAuthedRequest("master-agent", {
body: "请让后台队列自动切备用链",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed";
masterReply?: { accountId?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "openai-primary-queue");
assert.equal(payload.masterReplyState, "queued");
await waitFor(async () => {
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
return task?.status === "completed";
});
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.ok(task, "expected queued task to remain in state");
assert.equal(task?.status, "completed");
assert.equal(task?.accountId, "aliyun-qwen-backup-queue");
assert.equal(task?.deviceId, "master-agent-aliyun-qwen");
const aliyunAccount = state.aiAccounts.find((item) => item.accountId === "aliyun-qwen-backup-queue");
assert.equal(aliyunAccount?.isActive, true);
assert.equal(fetchCalls[0], "https://api.openai.com/v1/responses");
assert.equal(fetchCalls[1], "https://dashscope.aliyuncs.com/compatible-mode/v1/responses");
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -2,12 +2,13 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdir, mkdtemp, rm } from "node:fs/promises";
let runtimeRoot = ""; let runtimeRoot = "";
let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"]; let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"]; let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let readState: (typeof import("../src/lib/boss-data"))["readState"]; let readState: (typeof import("../src/lib/boss-data"))["readState"];
let updateAiAccountHealth: (typeof import("../src/lib/boss-data"))["updateAiAccountHealth"];
async function setup() { async function setup() {
if (runtimeRoot) return; if (runtimeRoot) return;
@@ -24,6 +25,7 @@ async function setup() {
replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage; replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage;
saveAiAccount = data.saveAiAccount; saveAiAccount = data.saveAiAccount;
readState = data.readState; readState = data.readState;
updateAiAccountHealth = data.updateAiAccountHealth;
} }
test.after(async () => { test.after(async () => {
@@ -32,9 +34,13 @@ test.after(async () => {
} }
}); });
test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account when the master node is offline", async () => { test.beforeEach(async () => {
await setup(); await setup();
await rm(runtimeRoot, { recursive: true, force: true });
await mkdir(runtimeRoot, { recursive: true });
});
test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account when the master node is offline", async () => {
await saveAiAccount({ await saveAiAccount({
accountId: "master-codex-primary", accountId: "master-codex-primary",
label: "主 GPT", label: "主 GPT",
@@ -101,9 +107,81 @@ test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account
} }
}); });
test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup account when the master node is offline", async () => { test("replyToMasterAgentUserMessage can retry the same degraded API account when it is the only available backend", async () => {
await setup(); await saveAiAccount({
accountId: "master-codex-primary",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "offline-node",
nodeLabel: "离线节点",
model: "gpt-5.4",
enabled: true,
setActive: false,
loginStatusNote: "测试中显式模拟默认主节点离线。",
});
await updateAiAccountHealth({
accountId: "master-codex-primary",
status: "degraded",
lastError: "MASTER_CODEX_NODE_DEVICE_OFFLINE",
lastValidatedAt: new Date().toISOString(),
});
await saveAiAccount({
accountId: "openai-primary-degraded",
label: "OpenAI 主控",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4",
apiKey: "sk-openai-only",
enabled: true,
setActive: true,
loginStatusNote: "唯一可用的 OpenAI 账号。",
});
await updateAiAccountHealth({
accountId: "openai-primary-degraded",
status: "degraded",
lastError: "temporary failure",
lastValidatedAt: new Date().toISOString(),
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
return new Response(JSON.stringify({ output_text: "仍然可以重试同一个 API 账号。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-openai-degraded-retry",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await replyToMasterAgentUserMessage({
requestMessageId: "msg-openai-degraded-retry",
requestText: "请只回复:仍然可以重试同一个 API 账号。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
});
assert.equal(result.ok, true);
assert.equal(result.accountId, "openai-primary-degraded");
const state = await readState();
const account = state.aiAccounts.find((item) => item.accountId === "openai-primary-degraded");
assert.equal(account?.status, "ready");
assert.equal(account?.isActive, true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup account when the master node is offline", async () => {
await saveAiAccount({ await saveAiAccount({
accountId: "master-codex-primary", accountId: "master-codex-primary",
label: "主 GPT", label: "主 GPT",
@@ -169,3 +247,125 @@ test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}); });
test("replyToMasterAgentUserMessage retries the next ready API backup when the first API backend call fails", async () => {
await saveAiAccount({
accountId: "openai-primary-ready",
label: "OpenAI 主控",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4",
apiKey: "sk-openai-primary",
enabled: true,
setActive: true,
loginStatusNote: "主 OpenAI 账号。",
});
await saveAiAccount({
accountId: "aliyun-qwen-backup",
label: "阿里备用",
role: "backup",
provider: "aliyun_qwen_api",
displayName: "阿里百炼备用账号",
accountIdentifier: "dashscope-demo",
model: "qwen3.5-plus",
apiKey: "sk-aliyun-demo-123456",
enabled: true,
setActive: false,
loginStatusNote: "阿里百炼 Qwen 备用账号。",
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
return new Response(JSON.stringify({ error: { message: "openai temporary failure" } }), {
status: 500,
headers: { "content-type": "application/json" },
});
}
if (typeof input === "string" && input === "https://dashscope.aliyuncs.com/compatible-mode/v1/responses") {
return new Response(JSON.stringify({ output_text: "阿里备用接管成功。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-api-chain",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await replyToMasterAgentUserMessage({
requestMessageId: "msg-master-api-chain",
requestText: "请只回复:阿里备用接管成功。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
});
assert.equal(result.ok, true);
assert.equal(result.accountId, "aliyun-qwen-backup");
assert.equal(result.requestId, "req-master-api-chain");
const state = await readState();
const openaiAccount = state.aiAccounts.find((item) => item.accountId === "openai-primary-ready");
assert.equal(openaiAccount?.status, "degraded");
const aliyunAccount = state.aiAccounts.find((item) => item.accountId === "aliyun-qwen-backup");
assert.equal(aliyunAccount?.isActive, true);
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected a fallback reply to be appended");
assert.match(reply?.body ?? "", /阿里备用接管成功/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("replyToMasterAgentUserMessage falls back to a ready backup master node account when API backends are unavailable", async () => {
await saveAiAccount({
accountId: "master-codex-primary-offline",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "离线主节点",
nodeId: "offline-node",
nodeLabel: "离线节点",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "离线主节点。",
});
await saveAiAccount({
accountId: "master-codex-backup-ready",
label: "备用主节点",
role: "backup",
provider: "master_codex_node",
displayName: "在线备用 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: false,
loginStatusNote: "在线备用主节点。",
});
const result = await replyToMasterAgentUserMessage({
requestMessageId: "msg-master-node-backup-fallback",
requestText: "请切到备用主节点。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
mode: "enqueue",
});
assert.equal(result.ok, true);
assert.equal(result.accountId, "master-codex-backup-ready");
assert.ok(result.taskId, "expected a queued master-agent task");
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === result.taskId);
assert.ok(task, "expected queued task to be written into state");
assert.equal(task?.accountId, "master-codex-backup-ready");
assert.equal(task?.deviceId, "mac-studio");
});