refactor: add execution backend selection
This commit is contained in:
@@ -610,8 +610,9 @@ export async function selectExecutionBackend(input: {
|
||||
primary: { provider: string; status: string };
|
||||
backups: Array<{ provider: string; status: string }>;
|
||||
}) {
|
||||
if (input.primary.provider === "master_codex_node" && input.primary.status === "ready") {
|
||||
return { backendId: "master-codex-node" };
|
||||
const primaryBackend = resolveBackendByProvider(input.primary.provider);
|
||||
if (input.primary.status === "ready") {
|
||||
return primaryBackend;
|
||||
}
|
||||
const qwen = input.backups.find((item) => item.provider === "aliyun_qwen_api" && item.status === "ready");
|
||||
if (qwen) {
|
||||
@@ -621,12 +622,20 @@ export async function selectExecutionBackend(input: {
|
||||
if (openai) {
|
||||
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;
|
||||
```
|
||||
|
||||
补充说明:
|
||||
- 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 的选择逻辑收进一个单独调用:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -138,6 +138,11 @@ interface ExecutionBackend {
|
||||
说明:
|
||||
|
||||
- 这层只负责“选择谁执行”,不负责组装 prompt,也不负责审批和业务决策
|
||||
- 运行时选择语义应明确为:
|
||||
- `ready primary` 优先
|
||||
- 否则按 `aliyun_qwen -> openai -> master_codex_node` 顺序回退
|
||||
- 如果没有任何 ready backend,再回 primary 兜底
|
||||
- 同一 provider 下如果存在多个账号,只要其中任一账号 `ready`,该 backend 就视为可选
|
||||
|
||||
### 3. `SessionRuntime`
|
||||
|
||||
|
||||
@@ -5560,6 +5560,39 @@ export async function getMasterAgentTask(taskId: string) {
|
||||
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) {
|
||||
let attachmentProjectId: string | undefined;
|
||||
let dispatchExecutionProjectId: string | undefined;
|
||||
|
||||
@@ -14,13 +14,22 @@ import {
|
||||
getMasterAgentTask,
|
||||
queueMasterAgentTask,
|
||||
readState,
|
||||
reassignMasterAgentTaskExecution,
|
||||
isDispatchableThreadProject,
|
||||
touchUserMasterMemories,
|
||||
updateAttachmentAnalysisResult,
|
||||
updateAiAccountHealth,
|
||||
} 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 { listExecutionBackendChoices, selectExecutionBackend } from "@/lib/execution/backend-selector";
|
||||
import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver";
|
||||
import type { RelevantMemory } from "@/lib/execution/memory-resolver";
|
||||
import { buildExecutionPrompt } from "@/lib/execution/prompt-assembler";
|
||||
@@ -363,38 +372,140 @@ function normalizeApiProviderFetchFailure(provider: ApiCompatibleProvider, error
|
||||
return normalizeApiProviderError(provider, String(error));
|
||||
}
|
||||
|
||||
function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
return 0;
|
||||
case "backup":
|
||||
return 1;
|
||||
case "api_fallback":
|
||||
return 2;
|
||||
default:
|
||||
return 9;
|
||||
}
|
||||
function isUsableApiAccount(account: AiAccount, provider: ApiCompatibleProvider) {
|
||||
return (
|
||||
account.enabled &&
|
||||
account.provider === provider &&
|
||||
(account.status === "ready" || account.status === "degraded") &&
|
||||
Boolean(account.apiKey?.trim())
|
||||
);
|
||||
}
|
||||
|
||||
async function findFallbackApiAccount(excludedAccountId?: string) {
|
||||
const state = await readState();
|
||||
return [...state.aiAccounts]
|
||||
.filter(
|
||||
(account) =>
|
||||
account.accountId !== excludedAccountId &&
|
||||
account.enabled &&
|
||||
isApiCompatibleProvider(account.provider) &&
|
||||
Boolean(account.apiKey?.trim()),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const roleDelta = fallbackAiRolePriority(left.role) - fallbackAiRolePriority(right.role);
|
||||
if (roleDelta !== 0) return roleDelta;
|
||||
return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "");
|
||||
})[0];
|
||||
function isUsableMasterNodeAccount(account: AiAccount) {
|
||||
return (
|
||||
account.enabled &&
|
||||
account.provider === "master_codex_node" &&
|
||||
account.status === "ready" &&
|
||||
Boolean(account.nodeId?.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function isOnlineMasterNodeAccount(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
account: AiAccount,
|
||||
) {
|
||||
if (!isUsableMasterNodeAccount(account)) {
|
||||
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: {
|
||||
account: Awaited<ReturnType<typeof findFallbackApiAccount>>;
|
||||
account: AiAccount;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
senderLabel: string;
|
||||
@@ -431,6 +542,10 @@ async function replyViaOpenAiAccount(params: {
|
||||
status: "ready",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
activate: !params.account.isActive,
|
||||
switchReason: params.account.isActive
|
||||
? params.account.switchReason
|
||||
: `主 Agent 回复时自动切换到 ${params.account.label}`,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -554,80 +669,146 @@ function buildMasterOpenAiReplyPrompt(
|
||||
}
|
||||
|
||||
async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
provider: ApiCompatibleProvider;
|
||||
candidates: ApiExecutionCandidate[];
|
||||
taskId: string;
|
||||
deviceId: string;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories?: 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(() => {
|
||||
void (async () => {
|
||||
const task = await getMasterAgentTask(params.taskId);
|
||||
if (!task || task.status !== "queued") {
|
||||
let lastErrorMessage = "主 Agent 当前调用模型失败。";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateApiProviderReply({
|
||||
provider: params.provider,
|
||||
apiKey: params.apiKey,
|
||||
model: params.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 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 当前调用模型失败。",
|
||||
});
|
||||
}
|
||||
await completeTaskSafely({
|
||||
taskId: params.taskId,
|
||||
deviceId: params.candidates[0]?.deviceId ?? OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
status: "failed",
|
||||
errorMessage: lastErrorMessage,
|
||||
});
|
||||
})();
|
||||
}, 0);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
async function enqueueOpenAiMasterAgentReply(params: {
|
||||
provider: ApiCompatibleProvider;
|
||||
accountId: string;
|
||||
accountLabel: string;
|
||||
candidates: ApiExecutionCandidate[];
|
||||
requestMessageId?: string;
|
||||
requestText: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
promptPolicy?: Awaited<ReturnType<typeof getMasterAgentPromptPolicyView>>;
|
||||
userPrompt?: Awaited<ReturnType<typeof getUserMasterPromptView>>;
|
||||
projectMemories?: 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 task = await queueMasterAgentTask({
|
||||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||||
@@ -644,29 +825,27 @@ async function enqueueOpenAiMasterAgentReply(params: {
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
accountId: params.accountId,
|
||||
accountLabel: params.accountLabel,
|
||||
deviceId: primaryCandidate.deviceId,
|
||||
accountId: primaryCandidate.account.accountId,
|
||||
accountLabel: primaryCandidate.account.label,
|
||||
});
|
||||
void queueAndStartOpenAiMasterAgentReply({
|
||||
provider: params.provider,
|
||||
candidates: params.candidates,
|
||||
taskId: task.taskId,
|
||||
deviceId: params.provider === "aliyun_qwen_api" ? ALIYUN_QWEN_DEVICE_ID : OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
apiKey: params.apiKey,
|
||||
model: params.model,
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
agentControls: params.agentControls,
|
||||
promptPolicy: params.promptPolicy,
|
||||
userPrompt: params.userPrompt,
|
||||
projectMemories: params.projectMemories,
|
||||
userMemories: params.userMemories,
|
||||
masterFallback: params.masterFallback,
|
||||
});
|
||||
|
||||
const queuedReply: QueuedMasterAgentReplyEnvelope = {
|
||||
ok: true as const,
|
||||
accountId: params.accountId,
|
||||
accountId: primaryCandidate.account.accountId,
|
||||
taskId: task.taskId,
|
||||
masterReplyState: "queued" as const,
|
||||
task: {
|
||||
@@ -1390,82 +1569,98 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
params.requestedByAccount,
|
||||
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 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") {
|
||||
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;
|
||||
const runMasterNodeExecution = async () => {
|
||||
if (!selectedMasterAccount) {
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},目标后端是 Master Codex Node,但当前没有可用的 master 节点账号。`,
|
||||
"请先把可用的 Master Codex Node 重新接回,再重试。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
|
||||
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 deviceId = selectedMasterAccount.nodeId || state.user.boundDeviceId || "mac-studio";
|
||||
const boundDevice = state.devices.find((device) => device.id === deviceId);
|
||||
const boundNodeLabel =
|
||||
selectedMasterAccount.nodeLabel?.trim() ||
|
||||
boundDevice?.name ||
|
||||
state.user.boundCodexNodeLabel ||
|
||||
deviceId;
|
||||
|
||||
const fallbackAccount = await findFallbackApiAccount(runtime.account.accountId);
|
||||
if (fallbackAccount?.apiKey?.trim() && isApiCompatibleProvider(fallbackAccount.provider)) {
|
||||
return enqueueOpenAiMasterAgentReply({
|
||||
provider: fallbackAccount.provider,
|
||||
accountId: fallbackAccount.accountId,
|
||||
accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role),
|
||||
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,
|
||||
if (!boundDevice || boundDevice.status !== "online") {
|
||||
await updateAiAccountHealth({
|
||||
accountId: selectedMasterAccount.accountId,
|
||||
status: "degraded",
|
||||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
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 = {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
accountId: selectedMasterAccount.accountId,
|
||||
taskId: task.taskId,
|
||||
masterReplyState: "queued" as const,
|
||||
task: {
|
||||
@@ -1477,117 +1672,16 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
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);
|
||||
if (completedTask?.status === "completed") {
|
||||
return {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
accountId: selectedMasterAccount.accountId,
|
||||
taskId: task.taskId,
|
||||
requestId: completedTask.requestId,
|
||||
};
|
||||
}
|
||||
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 {
|
||||
ok: false as const,
|
||||
reason: "MASTER_NODE_EXEC_FAILED",
|
||||
@@ -1601,9 +1695,86 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 Master Codex Node。`,
|
||||
"如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。",
|
||||
].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()) {
|
||||
@@ -1618,61 +1789,5 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
|
||||
97
src/lib/execution/backend-selector.ts
Normal file
97
src/lib/execution/backend-selector.ts
Normal 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;
|
||||
22
src/lib/execution/backends/aliyun-qwen-backend.ts
Normal file
22
src/lib/execution/backends/aliyun-qwen-backend.ts
Normal 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";
|
||||
}
|
||||
22
src/lib/execution/backends/master-codex-node-backend.ts
Normal file
22
src/lib/execution/backends/master-codex-node-backend.ts
Normal 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";
|
||||
}
|
||||
19
src/lib/execution/backends/openai-backend.ts
Normal file
19
src/lib/execution/backends/openai-backend.ts
Normal 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";
|
||||
}
|
||||
75
tests/execution-backend-selector.test.ts
Normal file
75
tests/execution-backend-selector.test.ts
Normal 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");
|
||||
});
|
||||
@@ -2,7 +2,7 @@ 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";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
@@ -71,9 +71,13 @@ test.after(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
|
||||
test.beforeEach(async () => {
|
||||
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({
|
||||
accountId: "openai-master-agent-queue",
|
||||
label: "API 容灾",
|
||||
@@ -120,10 +124,11 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
|
||||
ok: boolean;
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
masterReply?: unknown;
|
||||
masterReply?: { accountId?: string } | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.masterReply?.accountId, "openai-master-agent-queue");
|
||||
assert.equal(payload.masterReplyState, "queued");
|
||||
assert.ok(payload.task, "expected master-agent message to return a task envelope");
|
||||
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 () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary-offline",
|
||||
label: "主 GPT",
|
||||
@@ -214,8 +217,10 @@ test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队
|
||||
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-backup-queue");
|
||||
assert.equal(payload.masterReplyState, "queued");
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
|
||||
@@ -234,3 +239,150 @@ test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ 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";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"];
|
||||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let updateAiAccountHealth: (typeof import("../src/lib/boss-data"))["updateAiAccountHealth"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
@@ -24,6 +25,7 @@ async function setup() {
|
||||
replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage;
|
||||
saveAiAccount = data.saveAiAccount;
|
||||
readState = data.readState;
|
||||
updateAiAccountHealth = data.updateAiAccountHealth;
|
||||
}
|
||||
|
||||
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 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({
|
||||
accountId: "master-codex-primary",
|
||||
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 () => {
|
||||
await setup();
|
||||
test("replyToMasterAgentUserMessage can retry the same degraded API account when it is the only available backend", async () => {
|
||||
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({
|
||||
accountId: "master-codex-primary",
|
||||
label: "主 GPT",
|
||||
@@ -169,3 +247,125 @@ test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user