fix: route master agent through codex device pool
This commit is contained in:
@@ -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 校验、更新 / 卸载 / 回滚前备份和失败恢复;仍未做签名校验和依赖安装沙箱
|
||||
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
|
||||
|
||||
@@ -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`,要求先确认或拒绝当前推荐,避免审批消息叠加
|
||||
|
||||
@@ -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 自动导入主链
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<ReturnType<typeof readState>>,
|
||||
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<ReturnType<typeof readState>>;
|
||||
runtimeAccount: AiAccount;
|
||||
authorizedDeviceIds?: string[];
|
||||
excludeDeviceIds?: string[];
|
||||
}) {
|
||||
const seenAccountIds = new Set<string>();
|
||||
const seenDeviceIds = new Set<string>();
|
||||
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<ReturnType<typeof readState>>,
|
||||
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") {
|
||||
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: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||||
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" };
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<boolean>, 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 节点账号/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user