diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index 118d7a9..86f0be2 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -194,6 +194,7 @@ - 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md` - `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆 - `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾` +- 主 Agent 使用 `Master Codex Node` 时必须优先走授权 Codex 设备池:设备在线且 `Codex App Server / CLI / GUI` 至少一条模型通道在线才可用;首选设备不可用或执行失败会自动切下一台,全部 Codex 设备不可用后才使用用户配置的 API Key;如果两类通道都没有,APP 中提示“当前没有可用的模型渠道” - `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句 - Skill 远程治理第一版已经接通最高管理员后端入口和设备端执行:`GET/POST /api/v1/admin/skills/requests` 可创建和查看 `install / update / uninstall / rollback / version_lock` 请求,local-agent 通过 `claim / complete` 认领执行并回写最新 Skill 清单。当前设备端已增加 source allowlist / trusted source、`checksum / expectedChecksum` sha256 校验、更新 / 卸载 / 回滚前备份和失败恢复;仍未做签名校验和依赖安装沙箱 - `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 0dc355b..3275c4e 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -759,8 +759,8 @@ - `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本 - Telegram Gateway 当前也复用这条主 Agent 链路:Telegram 私聊文本会写入 `master-agent` 项目,快速回复直接返回,异步任务通过 `externalReplyTarget` 在完成后回推 Telegram - 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete` - - 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志 - - 如本机节点未接通,可切到 `OpenAI API` 或 `阿里百炼 Qwen` 备用账号 + - 如果当前主控是 `Master Codex Node`,主 Agent 会先使用授权范围内的 Codex 设备池:设备在线且 `Codex App Server / CLI / GUI` 至少一条模型通道在线才可作为模型通道;首选设备不可用会切下一台,执行中失败会把同一任务重排到下一台,全部 Codex 设备不可用后才切到已配置的 API 备用链 + - 如 Codex 设备池和 API Key 都不可用,主 Agent 会在对话里提示“当前没有可用的模型渠道”,不再暴露内部 master 节点账号话术 - 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批 - 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案 - 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 254ff0a..eb12c00 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -188,7 +188,7 @@ cd /Users/kris/code/boss - 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆都可以在 Web 端查看和编辑;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖 - 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新` - 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果存在新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口 -- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,不再把失败日志直接原样回给用户 +- 当前如果主控身份是 `Master Codex Node`,主 Agent 会先按授权范围构建 Codex 设备池:设备必须在线,且 `Codex App Server / CLI / GUI` 至少一条模型通道在线,`codexAppServer.metadata.accountSummary.signedIn=false` 会被视为不可用;首选设备不可用时会自动切到下一台可用 Codex 设备,执行中失败也会先重排到下一台设备,全部 Codex 设备不可用时才尝试已配置的 API 备用链;如果 Codex 设备池和 API Key 都不可用,APP 会提示“当前没有可用的模型渠道” - 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index f9a189d..1da1b5e 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -1333,6 +1333,7 @@ export interface MasterAgentTask { deviceId: string; accountId?: string; accountLabel?: string; + modelChannelAttemptedDeviceIds?: string[]; attachmentId?: string; attachmentFileName?: string; attachmentDownloadToken?: string; @@ -9915,6 +9916,123 @@ function isTerminalMasterAgentTaskStatus(status: MasterAgentTaskStatus) { return status === "completed" || status === "failed" || status === "timed_out" || status === "canceled"; } +function codexModelChannelAccountSummarySignedIn(device: Device) { + const metadata = device.capabilities?.codexAppServer?.metadata; + const accountSummary = + metadata && typeof metadata === "object" + ? (metadata.accountSummary as { signedIn?: unknown } | undefined) + : undefined; + return accountSummary?.signedIn !== false; +} + +function hasUsableCodexModelChannelInState(device: Device | undefined) { + if (!device || device.status !== "online" || isDeviceRevoked(device)) { + return false; + } + const capabilities = device.capabilities; + const hasCodexTransport = Boolean( + capabilities?.codexAppServer?.connected || + capabilities?.cli?.connected || + capabilities?.gui?.connected, + ); + return hasCodexTransport && codexModelChannelAccountSummarySignedIn(device); +} + +function isMasterCodexNodeTask(task: MasterAgentTask, state: BossState) { + if (task.taskType !== "conversation_reply") { + return false; + } + if (task.accountId?.startsWith("master-codex-device-")) { + return true; + } + const account = task.accountId + ? state.aiAccounts.find((item) => item.accountId === task.accountId) + : undefined; + return account?.provider === "master_codex_node"; +} + +function isTaskAuthorizedForDevice(task: MasterAgentTask, deviceId: string) { + return ( + !task.authorizedDeviceIds || + task.authorizedDeviceIds.length === 0 || + task.authorizedDeviceIds.includes(deviceId) + ); +} + +function sortMasterCodexFailoverDevices(left: Device, right: Device) { + return (right.lastSeenAt ?? "").localeCompare(left.lastSeenAt ?? ""); +} + +function findNextMasterCodexNodeCandidateForFailedTask( + state: BossState, + task: MasterAgentTask, + failedDeviceId: string, +) { + const excludedDeviceIds = new Set([failedDeviceId, ...(task.modelChannelAttemptedDeviceIds ?? [])]); + const seenDeviceIds = new Set(); + + const configuredCandidates = state.aiAccounts + .filter((account) => { + const deviceId = account.nodeId?.trim(); + if ( + !account.enabled || + account.provider !== "master_codex_node" || + (account.status !== "ready" && account.status !== "degraded") || + !deviceId || + excludedDeviceIds.has(deviceId) || + !isTaskAuthorizedForDevice(task, deviceId) + ) { + return false; + } + const device = state.devices.find((item) => item.id === deviceId); + return hasUsableCodexModelChannelInState(device); + }) + .sort((left, right) => { + if (left.isActive !== right.isActive) { + return left.isActive ? -1 : 1; + } + return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""); + }); + + const configured = configuredCandidates[0]; + if (configured?.nodeId) { + return { + deviceId: configured.nodeId, + accountId: configured.accountId, + accountLabel: configured.label, + }; + } + + for (const account of configuredCandidates) { + if (account.nodeId) { + seenDeviceIds.add(account.nodeId); + } + } + + const device = state.devices + .filter((item) => { + if ( + excludedDeviceIds.has(item.id) || + seenDeviceIds.has(item.id) || + !isTaskAuthorizedForDevice(task, item.id) + ) { + return false; + } + return hasUsableCodexModelChannelInState(item); + }) + .sort(sortMasterCodexFailoverDevices)[0]; + + if (!device) { + return null; + } + + return { + deviceId: device.id, + accountId: `master-codex-device-${device.id}`, + accountLabel: `Codex · ${device.name || device.id}`, + }; +} + function masterAgentTaskLeaseMs(task: MasterAgentTask) { return task.taskType === "conversation_reply" ? MASTER_AGENT_TASK_CONVERSATION_LEASE_MS @@ -10376,6 +10494,46 @@ export async function completeMasterAgentTask(payload: { dispatchExecution: undefined, }; } + if (payload.status === "failed" && isMasterCodexNodeTask(task, state)) { + const failover = findNextMasterCodexNodeCandidateForFailedTask(state, task, payload.deviceId); + if (failover) { + const failedAt = nowIso(); + const failedAccount = task.accountId + ? state.aiAccounts.find((item) => item.accountId === task.accountId) + : undefined; + if (failedAccount?.provider === "master_codex_node") { + failedAccount.status = "degraded"; + failedAccount.lastError = payload.errorMessage?.trim() || "MASTER_CODEX_NODE_EXEC_FAILED"; + failedAccount.lastValidatedAt = failedAt; + failedAccount.updatedAt = failedAt; + } + task.status = "queued"; + task.deviceId = failover.deviceId; + task.accountId = failover.accountId; + task.accountLabel = failover.accountLabel; + task.modelChannelAttemptedDeviceIds = [ + ...new Set([...(task.modelChannelAttemptedDeviceIds ?? []), payload.deviceId]), + ]; + task.completedAt = undefined; + task.canceledAt = undefined; + task.canceledBy = undefined; + task.cancelReason = undefined; + task.leaseExpiresAt = undefined; + task.claimedAt = undefined; + task.lastClaimedAt = undefined; + task.lastErrorKind = "model_channel_failover"; + task.errorMessage = payload.errorMessage?.trim() || "MASTER_CODEX_NODE_EXEC_FAILED"; + task.replyBody = undefined; + task.requestId = undefined; + upsertTaskExecutionProgressMessageInState(state, task, "queued", payload.executionProgress); + return { + ...task, + dispatchPlan: undefined, + dispatchExecution: undefined, + dialogGuardIntervention: undefined, + }; + } + } task.status = payload.status; task.completedAt = nowIso(); task.leaseExpiresAt = undefined; diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index c9fa344..a01b7f3 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -25,6 +25,7 @@ import type { AiAccount, AiProvider, AuthRole, + Device, BossPermission, DispatchPlanTarget, ExternalReplyTarget, @@ -1740,9 +1741,36 @@ function isUsableMasterNodeAccount(account: AiAccount) { ); } +function codexAppServerAccountSummarySignedIn(device: Device) { + const metadata = device.capabilities?.codexAppServer?.metadata; + const accountSummary = + metadata && typeof metadata === "object" + ? (metadata.accountSummary as { signedIn?: unknown } | undefined) + : undefined; + return accountSummary?.signedIn !== false; +} + +function hasUsableCodexModelChannel(device: Device | undefined) { + if (!device || device.status !== "online") { + return false; + } + const capabilities = device.capabilities; + const hasCodexTransport = Boolean( + capabilities?.codexAppServer?.connected || + capabilities?.cli?.connected || + capabilities?.gui?.connected, + ); + return hasCodexTransport && codexAppServerAccountSummarySignedIn(device); +} + +function isAuthorizedDeviceId(deviceId: string, authorizedDeviceIds?: string[]) { + return !authorizedDeviceIds || authorizedDeviceIds.length === 0 || authorizedDeviceIds.includes(deviceId); +} + function isOnlineMasterNodeAccount( state: Awaited>, account: AiAccount, + authorizedDeviceIds?: string[], ) { if (!isUsableMasterNodeAccount(account)) { return false; @@ -1751,8 +1779,11 @@ function isOnlineMasterNodeAccount( if (!deviceId) { return false; } + if (!isAuthorizedDeviceId(deviceId, authorizedDeviceIds)) { + return false; + } const device = state.devices.find((item) => item.id === deviceId); - return Boolean(device && device.status === "online"); + return hasUsableCodexModelChannel(device); } function sortSelectableAccounts(left: AiAccount, right: AiAccount) { @@ -1790,19 +1821,106 @@ function supportsResponsesReasoning(provider: ApiCompatibleProvider) { return provider === "openai_api" || provider === "hyzq_api"; } +function buildAutoDiscoveredMasterNodeAccount(device: Device): AiAccount { + const now = device.lastSeenAt || new Date(0).toISOString(); + return { + accountId: `master-codex-device-${device.id}`, + label: `Codex · ${device.name || device.id}`, + role: "backup", + provider: "master_codex_node", + displayName: `${device.name || device.id} 上的 Codex`, + nodeId: device.id, + nodeLabel: device.name || device.id, + model: "Codex", + enabled: true, + isActive: false, + status: "ready", + loginStatusNote: "由绑定设备 heartbeat 自动发现的 Codex 模型通道。", + lastValidatedAt: device.lastSeenAt, + lastUsedAt: device.lastSeenAt, + createdAt: now, + updatedAt: now, + }; +} + +function sortAutoDiscoveredCodexDevices(left: Device, right: Device) { + return (right.lastSeenAt ?? "").localeCompare(left.lastSeenAt ?? ""); +} + +function resolveMasterCodexNodeExecutionCandidates(params: { + state: Awaited>; + runtimeAccount: AiAccount; + authorizedDeviceIds?: string[]; + excludeDeviceIds?: string[]; +}) { + const seenAccountIds = new Set(); + const seenDeviceIds = new Set(); + const excludedDeviceIds = new Set(params.excludeDeviceIds ?? []); + const candidates: AiAccount[] = []; + + const pushConfiguredAccount = (account: AiAccount | undefined) => { + if (!account || seenAccountIds.has(account.accountId)) { + return; + } + const deviceId = account.nodeId?.trim(); + if (!deviceId || excludedDeviceIds.has(deviceId)) { + return; + } + if (!isOnlineMasterNodeAccount(params.state, account, params.authorizedDeviceIds)) { + return; + } + seenAccountIds.add(account.accountId); + seenDeviceIds.add(deviceId); + candidates.push(account); + }; + + if (params.runtimeAccount.provider === "master_codex_node") { + pushConfiguredAccount(params.runtimeAccount); + } + + for (const account of params.state.aiAccounts + .filter((item) => item.accountId !== params.runtimeAccount.accountId) + .sort(sortSelectableAccounts)) { + pushConfiguredAccount(account); + } + + for (const device of params.state.devices + .filter((item) => { + if (excludedDeviceIds.has(item.id) || seenDeviceIds.has(item.id)) { + return false; + } + if (!isAuthorizedDeviceId(item.id, params.authorizedDeviceIds)) { + return false; + } + return hasUsableCodexModelChannel(item); + }) + .sort(sortAutoDiscoveredCodexDevices)) { + const account = buildAutoDiscoveredMasterNodeAccount(device); + if (seenAccountIds.has(account.accountId)) { + continue; + } + seenAccountIds.add(account.accountId); + seenDeviceIds.add(device.id); + candidates.push(account); + } + + return candidates; +} + +function isPersistedAiAccount( + state: Awaited>, + account: AiAccount, +) { + return state.aiAccounts.some((item) => item.accountId === account.accountId); +} + 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]; + return resolveMasterCodexNodeExecutionCandidates({ state, runtimeAccount })[0] ?? null; } if (isApiCompatibleProvider(selectedBackendProvider)) { @@ -1993,13 +2111,16 @@ export async function tryBuildLocalMasterAgentFastReply(params: { async function resolveMasterNodeExecutionCandidate(params: { backendChoices: Array<{ backendId: string; provider?: AiProvider }>; runtimeAccount: AiAccount; + candidates?: 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); + const account = + params.candidates?.[0] ?? + (await resolveAccountForSelectedBackend("master_codex_node", params.runtimeAccount)); return account && account.provider === "master_codex_node" ? account : null; } @@ -4160,10 +4281,13 @@ export async function replyToMasterAgentUserMessage(params: { return { ok: false as const, reason: "FORBIDDEN" }; } const replyProject = authorizedState.projects.find((project) => project.id === replyProjectId); - const primaryDeviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio"; - const primaryDevice = state.devices.find((device) => device.id === primaryDeviceId); + const masterNodeCandidates = resolveMasterCodexNodeExecutionCandidates({ + state, + runtimeAccount: runtime.account, + authorizedDeviceIds: authorizedScope.authorizedDeviceIds, + }); const primaryBackendStatus = - runtime.account.provider === "master_codex_node" && (!primaryDevice || primaryDevice.status !== "online") + runtime.account.provider === "master_codex_node" && masterNodeCandidates.length === 0 ? "degraded" : runtime.account.status; const clawSelectionState = await getClawBackendSelectionState(); @@ -4176,7 +4300,11 @@ export async function replyToMasterAgentUserMessage(params: { .filter((account) => account.accountId !== runtime.account.accountId) .map((account) => ({ provider: account.provider, - status: account.status, + status: + account.provider === "master_codex_node" && + !isOnlineMasterNodeAccount(state, account, authorizedScope.authorizedDeviceIds) + ? "degraded" + : account.status, })), requestKind: "master_agent_reply" as const, requestedBackendId: executionConfig.agentControls?.backendOverride, @@ -4194,6 +4322,7 @@ export async function replyToMasterAgentUserMessage(params: { const selectedMasterAccount = await resolveMasterNodeExecutionCandidate({ backendChoices, runtimeAccount: runtime.account, + candidates: masterNodeCandidates, }); const apiExecutionCandidates = await buildApiExecutionCandidates({ backendChoices, @@ -4333,8 +4462,8 @@ export async function replyToMasterAgentUserMessage(params: { if (!selectedMasterAccount) { await appendMasterAgentSystemReply( [ - `当前主控身份是 ${runtime.summary.roleLabel},目标后端是 Master Codex Node,但当前没有可用的 master 节点账号。`, - "请先把可用的 Master Codex Node 重新接回,再重试。", + "当前没有可用的模型渠道。", + "请先接回一台已经登录 Codex / ChatGPT 的电脑,或到“我的 > AI 账号”配置可用 API Key 后再重试。", ].join(""), `主 Agent · ${runtime.summary.roleLabel}`, replyProjectId, @@ -4351,15 +4480,27 @@ export async function replyToMasterAgentUserMessage(params: { state.user.boundCodexNodeLabel || deviceId; - 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(), - }); + if (!hasUsableCodexModelChannel(boundDevice)) { + const lastError = !boundDevice + ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" + : boundDevice.status !== "online" + ? "MASTER_CODEX_NODE_DEVICE_OFFLINE" + : "MASTER_CODEX_NODE_CHANNEL_UNAVAILABLE"; + if (isPersistedAiAccount(state, selectedMasterAccount)) { + await updateAiAccountHealth({ + accountId: selectedMasterAccount.accountId, + status: "degraded", + lastError, + lastValidatedAt: new Date().toISOString(), + }); + } + const unavailableReason = !boundDevice + ? "未找到" + : boundDevice.status !== "online" + ? "不在线" + : "没有检测到可用的 Codex 模型通道"; await appendMasterAgentSystemReply( - `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`, + `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel} ${unavailableReason},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 boss-agent 在线后再重试。`, `主 Agent · ${selectedMasterAccount.label || runtime.summary.roleLabel}`, replyProjectId, params.requestedByAccount, @@ -4367,12 +4508,14 @@ export async function replyToMasterAgentUserMessage(params: { return { ok: false as const, reason: "MASTER_NODE_OFFLINE" }; } - await updateAiAccountHealth({ - accountId: selectedMasterAccount.accountId, - status: "ready", - lastValidatedAt: new Date().toISOString(), - lastUsedAt: new Date().toISOString(), - }); + if (isPersistedAiAccount(state, selectedMasterAccount)) { + await updateAiAccountHealth({ + accountId: selectedMasterAccount.accountId, + status: "ready", + lastValidatedAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }); + } if ( controlIntent.intentCategory === "browser_control" || controlIntent.intentCategory === "desktop_control" diff --git a/tests/master-agent-message-queue.test.ts b/tests/master-agent-message-queue.test.ts index 02baa4c..afc252e 100644 --- a/tests/master-agent-message-queue.test.ts +++ b/tests/master-agent-message-queue.test.ts @@ -10,6 +10,7 @@ let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route" let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"]; let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"]; let updateAiAccountHealth: (typeof import("../src/lib/boss-data"))["updateAiAccountHealth"]; +let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; let appendProjectMessages: (typeof import("../src/lib/boss-data"))["appendProjectMessages"]; @@ -34,6 +35,7 @@ async function setup() { saveAiAccount = data.saveAiAccount; updateProjectAgentControls = data.updateProjectAgentControls; updateAiAccountHealth = data.updateAiAccountHealth; + updateDevice = data.updateDevice; readState = data.readState; createAuthSession = data.createAuthSession; appendProjectMessages = data.appendProjectMessages; @@ -844,6 +846,14 @@ test("POST /api/v1/projects/master-agent/messages 在主节点在线时复杂快 }); test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => { + await updateDevice("mac-studio", { + capabilities: { + gui: { connected: false }, + cli: { connected: false }, + codexAppServer: { connected: false }, + }, + }); + await saveAiAccount({ accountId: "master-codex-primary-offline", label: "主 GPT", diff --git a/tests/master-agent-openai-fallback.test.ts b/tests/master-agent-openai-fallback.test.ts index 8bbe444..5a0ed9d 100644 --- a/tests/master-agent-openai-fallback.test.ts +++ b/tests/master-agent-openai-fallback.test.ts @@ -10,6 +10,9 @@ 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"]; let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"]; +let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"]; +let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"]; +let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"]; async function setup() { if (runtimeRoot) return; @@ -28,6 +31,9 @@ async function setup() { readState = data.readState; updateAiAccountHealth = data.updateAiAccountHealth; updateProjectAgentControls = data.updateProjectAgentControls; + updateDevice = data.updateDevice; + upsertDeviceHeartbeat = data.upsertDeviceHeartbeat; + completeMasterAgentTask = data.completeMasterAgentTask; } async function waitFor(predicate: () => Promise, timeoutMs = 5_000) { @@ -54,6 +60,14 @@ test.beforeEach(async () => { }); test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account when the master node is offline", async () => { + await updateDevice("mac-studio", { + capabilities: { + gui: { connected: false }, + cli: { connected: false }, + codexAppServer: { connected: false }, + }, + }); + await saveAiAccount({ accountId: "master-codex-primary", label: "主 GPT", @@ -253,6 +267,14 @@ test("replyToMasterAgentUserMessage uses active DeepSeek API accounts directly", }); test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup account when the master node is offline", async () => { + await updateDevice("mac-studio", { + capabilities: { + gui: { connected: false }, + cli: { connected: false }, + codexAppServer: { connected: false }, + }, + }); + await saveAiAccount({ accountId: "master-codex-primary", label: "主 GPT", @@ -508,3 +530,256 @@ test("replyToMasterAgentUserMessage falls back to a ready backup master node acc assert.equal(task?.accountId, "master-codex-backup-ready"); assert.equal(task?.deviceId, "mac-studio"); }); + +test("replyToMasterAgentUserMessage routes to another bound Codex device when the active node has no usable Codex channel", async () => { + await updateDevice("mac-studio", { + status: "online", + capabilities: { + gui: { connected: false }, + cli: { connected: false }, + codexAppServer: { connected: false }, + }, + }); + await upsertDeviceHeartbeat({ + deviceId: "macbook-air-codex", + token: "boss-macbook-air-codex-token", + name: "MacBook Air", + avatar: "A", + account: "krisolo", + status: "online", + quota5h: 90, + quota7d: 90, + projects: [], + capabilities: { + gui: { connected: true }, + cli: { connected: true }, + codexAppServer: { connected: true }, + }, + }); + + await saveAiAccount({ + accountId: "master-codex-primary-no-channel", + label: "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: "在线但没有 Codex 通道的主节点", + nodeId: "mac-studio", + nodeLabel: "Mac Studio", + model: "gpt-5.4", + enabled: true, + setActive: true, + loginStatusNote: "模拟 boss-agent 未检测到可用 Codex 通道。", + }); + + const result = await replyToMasterAgentUserMessage({ + requestMessageId: "msg-master-node-device-pool", + requestText: "请使用可用的 Codex 设备池。", + requestedBy: "Boss 超级管理员", + requestedByAccount: "krisolo", + mode: "enqueue", + }); + + assert.equal(result.ok, true); + assert.equal(result.accountId, "master-codex-device-macbook-air-codex"); + 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-device-macbook-air-codex"); + assert.equal(task?.deviceId, "macbook-air-codex"); +}); + +test("replyToMasterAgentUserMessage falls back to API when no bound Codex model channel is usable", async () => { + await updateDevice("mac-studio", { + status: "online", + capabilities: { + gui: { connected: false }, + cli: { connected: false }, + codexAppServer: { connected: false }, + }, + }); + + await saveAiAccount({ + accountId: "master-codex-primary-no-model-channel", + label: "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: "不可用的 Master Codex Node", + nodeId: "mac-studio", + nodeLabel: "Mac Studio", + model: "gpt-5.4", + enabled: true, + setActive: true, + loginStatusNote: "模拟没有任何可用 Codex 模型通道。", + }); + + await saveAiAccount({ + accountId: "openai-api-after-codex-pool", + label: "API 备用", + role: "api_fallback", + provider: "openai_api", + displayName: "OpenAI API 备用账号", + model: "gpt-5.4-mini", + apiKey: "sk-openai-after-codex-pool", + enabled: true, + setActive: false, + loginStatusNote: "Codex 设备池不可用时兜底。", + }); + + 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-api-after-codex-pool", + }, + }); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }) as typeof fetch; + + try { + const result = await replyToMasterAgentUserMessage({ + requestMessageId: "msg-api-after-codex-pool", + requestText: "Codex 设备都不可用时继续回复。", + requestedBy: "Boss 超级管理员", + requestedByAccount: "krisolo", + mode: "enqueue", + }); + + assert.equal(result.ok, true); + assert.equal(result.accountId, "openai-api-after-codex-pool"); + assert.ok(result.taskId, "expected an API fallback 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, "openai-api-after-codex-pool"); + assert.equal(task?.deviceId, "master-agent-openai"); + + await waitFor(async () => { + const nextState = await readState(); + const completedTask = nextState.masterAgentTasks.find((item) => item.taskId === result.taskId); + return completedTask?.status === "completed"; + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("failed Master Codex Node task is requeued to the next usable Codex device before surfacing an error", async () => { + await updateDevice("mac-studio", { + status: "online", + capabilities: { + gui: { connected: true }, + cli: { connected: true }, + codexAppServer: { connected: true }, + }, + }); + await upsertDeviceHeartbeat({ + deviceId: "macbook-air-codex-failover", + token: "boss-macbook-air-codex-failover-token", + name: "MacBook Air", + avatar: "A", + account: "krisolo", + status: "online", + quota5h: 90, + quota7d: 90, + projects: [], + capabilities: { + gui: { connected: true }, + cli: { connected: true }, + codexAppServer: { connected: true }, + }, + }); + + await saveAiAccount({ + accountId: "master-codex-primary-runtime-failover", + label: "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: "Mac Studio Master Codex Node", + nodeId: "mac-studio", + nodeLabel: "Mac Studio", + model: "gpt-5.4", + enabled: true, + setActive: true, + loginStatusNote: "首选 Master Codex Node。", + }); + + const result = await replyToMasterAgentUserMessage({ + requestMessageId: "msg-master-node-runtime-failover", + requestText: "请走主节点,如果失败自动切下一台。", + requestedBy: "Boss 超级管理员", + requestedByAccount: "krisolo", + mode: "enqueue", + }); + + assert.equal(result.ok, true); + assert.equal(result.accountId, "master-codex-primary-runtime-failover"); + assert.ok(result.taskId, "expected a queued master-agent task"); + + const requeued = await completeMasterAgentTask({ + taskId: result.taskId, + deviceId: "mac-studio", + status: "failed", + errorMessage: "spawn codex ENOENT", + }); + + assert.equal(requeued.status, "queued"); + assert.equal(requeued.deviceId, "macbook-air-codex-failover"); + assert.equal(requeued.accountId, "master-codex-device-macbook-air-codex-failover"); + assert.deepEqual(requeued.modelChannelAttemptedDeviceIds, ["mac-studio"]); + + const state = await readState(); + const task = state.masterAgentTasks.find((item) => item.taskId === result.taskId); + assert.equal(task?.status, "queued"); + assert.equal(task?.deviceId, "macbook-air-codex-failover"); + const masterProject = state.projects.find((project) => project.id === "master-agent"); + assert.doesNotMatch(masterProject?.messages.at(-1)?.body ?? "", /Master Codex Node 执行失败/); +}); + +test("replyToMasterAgentUserMessage tells the user when neither Codex devices nor API keys are available", async () => { + await updateDevice("mac-studio", { + status: "online", + capabilities: { + gui: { connected: false }, + cli: { connected: false }, + codexAppServer: { connected: false }, + }, + }); + + await saveAiAccount({ + accountId: "master-codex-primary-no-channel-no-api", + label: "主 GPT", + role: "primary", + provider: "master_codex_node", + displayName: "不可用的 Master Codex Node", + nodeId: "mac-studio", + nodeLabel: "Mac Studio", + model: "gpt-5.4", + enabled: true, + setActive: true, + loginStatusNote: "没有可用模型通道。", + }); + + const result = await replyToMasterAgentUserMessage({ + requestMessageId: "msg-no-model-channel", + requestText: "现在还能回复吗?", + requestedBy: "Boss 超级管理员", + requestedByAccount: "krisolo", + mode: "enqueue", + }); + + assert.equal(result.ok, false); + assert.equal(result.reason, "MASTER_NODE_NOT_CONNECTED"); + + const state = await readState(); + const masterProject = state.projects.find((project) => project.id === "master-agent"); + const reply = masterProject?.messages.at(-1); + assert.match(reply?.body ?? "", /当前没有可用的模型渠道/); + assert.doesNotMatch(reply?.body ?? "", /master 节点账号/); +});