feat: execute omx dispatches via local-agent

This commit is contained in:
kris
2026-04-03 03:51:16 +08:00
parent ec45bed59f
commit 24241d1f64
12 changed files with 402 additions and 46 deletions

View File

@@ -60,7 +60,7 @@
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime`
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
- 当前仓库已自带一个本地 OMX smoke runtime`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> fallback` 这条链
- 当前仓库已自带一个本地 OMX smoke runtime`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链
- 当前仓库已自带一个本地 smoke runtime`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
@@ -101,7 +101,7 @@ Android APK
- 已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.9``versionCode=22`
- 当前最新 release 构建版本:`2.5.10``versionCode=23`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
@@ -214,7 +214,8 @@ device-agent 当前职责:
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
- `local-agent``conversation_reply / dispatch_execution` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- `local-agent``conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- `local-agent``dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
- `local-agent` 当前的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成结果都会先归一到统一远程执行结果结构,再进入主 Agent 完成路由
- `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll避免控制面短暂阻塞时本地健康检查一起挂死
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应

View File

@@ -36,8 +36,8 @@ android {
applicationId "com.hyzq.boss"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 22
versionName "2.5.9"
versionCode 23
versionName "2.5.10"
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -91,6 +91,7 @@
- 当前额外职责:向云端上报 `thread-context`
- 当前新增职责:递归扫描本机 `~/.codex/skills` 并同步到设备 Skill 接口
- 当前完成回写:`conversation_reply / dispatch_execution` 会先标准化成统一远程执行结果,再调用 `/api/v1/master-agent/tasks/[taskId]/complete`
- 当前 `dispatch_execution` 会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`,显式选择 `omx-team` 且本机配置可用时改走 `OMX Team Runtime` JSON 协议
### 1.4 Caddy
@@ -181,7 +182,7 @@
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime可用于本地和服务器验证 `ClawBackendAdapter`
- 当前已最小接入 `OmxTeamBackendAdapter`但默认关闭Web 群聊详情页和原生群资料页已经可以在 `Boss Native``OMX Team` 间切换编排后端OMX 不可用时会自动回退到默认后端并返回明确原因
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter`
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter``dispatch_execution` JSON 协议
### 3.2 认证相关
@@ -1061,7 +1062,8 @@
- local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim`
- 认领到任务后会执行本机 `codex exec`
- `conversation_reply / dispatch_execution` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral`
- `conversation_reply` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral`
- `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议
- 执行完成后会调用 `POST /api/v1/master-agent/tasks/[taskId]/complete`
- 对群聊下发链路,认领到的 `dispatch_execution` 任务会带 `dispatchExecutionId / targetProjectId / targetThreadId`
- 对普通单线程聊天,认领到的 `conversation_reply` 任务会带 `targetProjectId / targetThreadId / targetCodexThreadRef`

View File

@@ -32,7 +32,7 @@
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
- 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node``BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
- 当前仓库已自带 `scripts/omx-team-smoke.mjs` 作为本地 OMX smoke runtime在没有真实 `oh-my-codex` 可执行文件时,可先用 `BOSS_OMX_COMMAND=node``BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs` 验证编排后端骨架
- 当前仓库已自带 `scripts/omx-team-smoke.mjs` 作为本地 OMX smoke runtime在没有真实 `oh-my-codex` 可执行文件时,可先用 `BOSS_OMX_COMMAND=node``BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs` 验证 `dispatch_execution` 的真实执行 contract
本地已知运行方式:
@@ -165,7 +165,7 @@ cd /Users/kris/code/boss
- 当前已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 当前已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- 当前 release 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.9``versionCode=22`
- 当前最新 release 构建版本:`2.5.10``versionCode=23`
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
@@ -185,11 +185,12 @@ cd /Users/kris/code/boss
- `2.5.4` 已把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从绿色 `soft panel` 降成轻量列表说明,和会话/设备页统一成同一套微信式产品语言
- `2.5.5` 已补上群资料页“修复群成员”主链:历史脏群现在会明确提示失效成员,并允许重新选择真实线程成员写回群资料
- `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程
- `2.5.9` 对应这一轮的执行底座收口:`ClawBackendAdapter` 仍默认关闭,但可显式选择并在不可用时自动回退;`OmxTeamBackendAdapter`接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换
- `2.5.10` 对应这一轮的执行底座收口:`ClawBackendAdapter` 仍默认关闭,但可显式选择并在不可用时自动回退;`OmxTeamBackendAdapter`不只是设置项,`dispatch_execution` 在显式选择 `omx-team` 且本机配置可用时会真实走 `OMX Team Runtime` JSON 协议执行
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
- 当前 `local-agent``conversation_reply / dispatch_execution` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- 当前 `local-agent``conversation_reply` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- 当前 `local-agent``dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody`
- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员
- 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议
- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll避免控制面短时阻塞时本地健康探针不可用

View File

@@ -9,6 +9,11 @@
"masterAgentWorkdir": "/Users/kris/code/boss",
"masterAgentSandbox": "workspace-write",
"masterAgentModel": "gpt-5.4",
"omxEnabled": false,
"omxCommand": "",
"omxArgs": [],
"omxWorkdir": "/Users/kris/code/boss",
"omxTimeoutMs": 45000,
"deviceId": "mac-studio",
"workerId": "worker-mac-ui",
"pairingCode": "",

View File

@@ -9,6 +9,11 @@
"masterAgentWorkdir": "/Users/kris/code/boss",
"masterAgentSandbox": "workspace-write",
"masterAgentModel": "gpt-5.4",
"omxEnabled": false,
"omxCommand": "",
"omxArgs": [],
"omxWorkdir": "/Users/kris/code/boss",
"omxTimeoutMs": 45000,
"deviceId": "mac-studio",
"workerId": "worker-mac-ui",
"pairingCode": "",

View File

@@ -0,0 +1,210 @@
import { spawn } from "node:child_process";
import path from "node:path";
function parseBoolean(value) {
return String(value || "").trim().toLowerCase() === "true";
}
function parseArgs(value) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseTimeoutMs(value) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
}
function resolveCommandArgs(command, args, cwd) {
const runtimeName = path.basename(command || "").toLowerCase();
const scriptRuntimes = new Set([
"node",
"node.exe",
"tsx",
"tsx.cmd",
"bun",
"bun.exe",
"deno",
"deno.exe",
]);
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
return args;
}
const [first, ...rest] = args;
if (!first || first.startsWith("-")) {
return args;
}
const resolvedFirst = path.isAbsolute(first)
? first
: path.resolve(cwd || process.cwd(), first);
return [resolvedFirst, ...rest];
}
function pickConfigValue(config, key, fallback) {
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
return config[key];
}
return fallback;
}
export function getOmxTeamTaskRunnerConfig(env = process.env, config = {}) {
const enabled = parseBoolean(pickConfigValue(config, "omxEnabled", env.BOSS_OMX_ENABLED));
const command = String(pickConfigValue(config, "omxCommand", env.BOSS_OMX_COMMAND) || "").trim() || undefined;
const args = Array.isArray(config?.omxArgs)
? config.omxArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "omxArgs", env.BOSS_OMX_ARGS));
const cwd = String(pickConfigValue(config, "omxWorkdir", env.BOSS_OMX_WORKDIR) || "").trim() || undefined;
const timeoutMs = parseTimeoutMs(pickConfigValue(config, "omxTimeoutMs", env.BOSS_OMX_TIMEOUT_MS));
return {
enabled,
command,
args,
cwd,
timeoutMs,
};
}
export function shouldUseOmxTeamTaskRunner(task) {
return task?.taskType === "dispatch_execution" && String(task?.orchestrationBackendId || "").trim() === "omx-team";
}
export function buildOmxTeamTaskExecution(config, task) {
if (!config?.enabled) {
throw new Error("OMX_TEAM_RUNTIME_DISABLED");
}
if (!config?.command) {
throw new Error("OMX_TEAM_COMMAND_REQUIRED");
}
const cwd = config.cwd || process.cwd();
return {
command: config.command,
args: resolveCommandArgs(config.command, config.args || [], cwd),
cwd,
timeoutMs: config.timeoutMs || 45000,
stdinPayload: {
requestKind: "dispatch_execution",
requestId: String(task?.taskId || "").trim(),
dispatchExecutionId: String(task?.dispatchExecutionId || "").trim(),
groupProjectId: String(task?.projectId || "").trim(),
targetProjectId: String(task?.targetProjectId || "").trim(),
targetThreadId: String(task?.targetThreadId || "").trim(),
targetThreadDisplayName: String(task?.targetThreadDisplayName || "").trim() || undefined,
objective: String(task?.executionPrompt || task?.requestText || "").trim(),
orchestrationBackendId: "omx-team",
workersRequested: 1,
context: {
requestedBy: String(task?.requestedByAccount || task?.requestedBy || "").trim() || undefined,
requestedAt: String(task?.requestedAt || "").trim() || undefined,
},
},
};
}
function parseJsonLine(rawOutput) {
const lines = String(rawOutput || "")
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const candidate = lines.at(-1) || "";
return JSON.parse(candidate);
}
export function parseOmxTeamTaskResult(rawOutput) {
const parsed = parseJsonLine(rawOutput);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("INVALID_OMX_RUNTIME_PAYLOAD");
}
if (parsed.status === "failed") {
return {
status: "failed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
dispatchExecutionId:
typeof parsed.dispatchExecutionId === "string" ? parsed.dispatchExecutionId.trim() || undefined : undefined,
errorMessage:
typeof parsed.error === "string" && parsed.error.trim()
? parsed.error.trim()
: "OMX_EXECUTION_FAILED",
};
}
const rawThreadReply =
typeof parsed.rawThreadReply === "string" && parsed.rawThreadReply.trim()
? parsed.rawThreadReply.trim()
: typeof parsed.summary === "string" && parsed.summary.trim()
? parsed.summary.trim()
: typeof parsed.replyBody === "string" && parsed.replyBody.trim()
? parsed.replyBody.trim()
: "";
if (!rawThreadReply) {
throw new Error("INVALID_OMX_RUNTIME_PAYLOAD");
}
return {
status: "completed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
dispatchExecutionId:
typeof parsed.dispatchExecutionId === "string" ? parsed.dispatchExecutionId.trim() || undefined : undefined,
rawThreadReply,
replyBody:
typeof parsed.replyBody === "string" && parsed.replyBody.trim()
? parsed.replyBody.trim()
: undefined,
};
}
export async function executeOmxTeamTask(config, task) {
const execution = buildOmxTeamTaskExecution(config, task);
return new Promise((resolve, reject) => {
const child = spawn(execution.command, execution.args, {
cwd: execution.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, execution.timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error("OMX_EXECUTION_TIMEOUT"));
return;
}
if (code !== 0) {
reject(new Error(stderr.trim() || `omx exit code ${code}`));
return;
}
try {
resolve(parseOmxTeamTaskResult(stdout));
} catch (error) {
reject(error);
}
});
child.stdin.write(JSON.stringify(execution.stdinPayload));
child.stdin.end();
});
}

View File

@@ -7,6 +7,11 @@ import os from "node:os";
import { join, resolve } from "node:path";
import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs";
import { buildCodexTaskExecution } from "./codex-task-runner.mjs";
import {
executeOmxTeamTask,
getOmxTeamTaskRunnerConfig,
shouldUseOmxTeamTaskRunner,
} from "./omx-team-task-runner.mjs";
import { createSerializedRunner } from "./serialized-runner.mjs";
async function loadConfig(configPath) {
@@ -364,32 +369,48 @@ async function runMasterAgentTask(config, runtime, task) {
};
try {
const codexExecution = buildCodexTaskExecution(config, task, outputFile);
await new Promise((resolveTask, rejectTask) => {
const child = spawn("codex", codexExecution.args, {
cwd: codexExecution.cwd,
env: process.env,
let replyBody;
let dispatchExecutionCompletion = null;
if (shouldUseOmxTeamTaskRunner(task)) {
const omxResult = await executeOmxTeamTask(getOmxTeamTaskRunnerConfig(process.env, config), task);
if (omxResult.status === "failed") {
throw new Error(omxResult.errorMessage || "OMX_EXECUTION_FAILED");
}
replyBody = omxResult.replyBody ?? omxResult.rawThreadReply;
dispatchExecutionCompletion = {
rawThreadReply: omxResult.rawThreadReply,
replyBody: omxResult.replyBody,
};
} else {
const codexExecution = buildCodexTaskExecution(config, task, outputFile);
await new Promise((resolveTask, rejectTask) => {
const child = spawn("codex", codexExecution.args, {
cwd: codexExecution.cwd,
env: process.env,
});
child.stderr.on("data", (chunk) => {
stderrChunks.push(String(chunk));
});
child.on("error", rejectTask);
child.on("close", (code) => {
if (code === 0) {
resolveTask();
return;
}
rejectTask(new Error(stderrChunks.join("").trim() || `codex exit code ${code}`));
});
});
child.stderr.on("data", (chunk) => {
stderrChunks.push(String(chunk));
});
replyBody = (await readFile(outputFile, "utf8")).trim();
dispatchExecutionCompletion =
task.taskType === "dispatch_execution"
? parseDispatchExecutionCompletion(replyBody)
: null;
}
child.on("error", rejectTask);
child.on("close", (code) => {
if (code === 0) {
resolveTask();
return;
}
rejectTask(new Error(stderrChunks.join("").trim() || `codex exit code ${code}`));
});
});
const replyBody = (await readFile(outputFile, "utf8")).trim();
const dispatchExecutionCompletion =
task.taskType === "dispatch_execution"
? parseDispatchExecutionCompletion(replyBody)
: null;
const completion = await completeMasterAgentTask(
config,
runtime,

View File

@@ -56,7 +56,12 @@ const objective =
: "OMX Team 链路正常";
writeJson({
status: "ready",
status: "completed",
backendId: "omx-team",
summary: `OMX smoke ready: ${objective} (kind=${requestKind}, workers=${workersRequested})`,
requestId: typeof payload.requestId === "string" ? payload.requestId : undefined,
dispatchExecutionId:
typeof payload.dispatchExecutionId === "string" ? payload.dispatchExecutionId : undefined,
rawThreadReply: `OMX smoke completed: ${objective} (kind=${requestKind}, workers=${workersRequested})`,
replyBody: `主 Agent 汇总:${objective}`,
summary: `OMX smoke completed: ${objective} (kind=${requestKind}, workers=${workersRequested})`,
});

View File

@@ -738,13 +738,6 @@ async function queueAndStartOpenAiMasterAgentReply(params: {
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",
@@ -755,6 +748,13 @@ async function queueAndStartOpenAiMasterAgentReply(params: {
? candidate.account.switchReason
: `主 Agent 回复时自动切换到 ${candidate.account.label}`,
});
await completeTaskSafely({
taskId: params.taskId,
deviceId: candidate.deviceId,
status: "completed",
replyBody: generated.content,
requestId: generated.requestId,
});
return;
} catch (error) {
if (error instanceof Error && error.message === "MASTER_AGENT_TASK_NOT_FOUND") {

View File

@@ -0,0 +1,100 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import {
buildOmxTeamTaskExecution,
getOmxTeamTaskRunnerConfig,
parseOmxTeamTaskResult,
shouldUseOmxTeamTaskRunner,
} from "../local-agent/omx-team-task-runner.mjs";
test("dispatch execution with omx backend builds OMX runtime payload", () => {
const config = getOmxTeamTaskRunnerConfig({
BOSS_OMX_ENABLED: "true",
BOSS_OMX_COMMAND: process.execPath,
BOSS_OMX_ARGS: "scripts/omx-team-smoke.mjs",
BOSS_OMX_WORKDIR: "/Users/kris/code/boss",
BOSS_OMX_TIMEOUT_MS: "32000",
});
const execution = buildOmxTeamTaskExecution(config, {
taskId: "mastertask-omx-1",
taskType: "dispatch_execution",
orchestrationBackendId: "omx-team",
dispatchExecutionId: "dx-omx-1",
projectId: "group-project-1",
targetProjectId: "thread-project-1",
targetThreadId: "thread-1",
targetThreadDisplayName: "前端主线程",
executionPrompt: "请执行群聊任务",
requestedByAccount: "kris",
requestedAt: "2026-04-03T12:00:00.000Z",
});
assert.equal(execution.command, process.execPath);
assert.deepEqual(execution.args, [path.resolve("/Users/kris/code/boss", "scripts/omx-team-smoke.mjs")]);
assert.equal(execution.cwd, "/Users/kris/code/boss");
assert.equal(execution.timeoutMs, 32000);
assert.equal(execution.stdinPayload.requestKind, "dispatch_execution");
assert.equal(execution.stdinPayload.requestId, "mastertask-omx-1");
assert.equal(execution.stdinPayload.dispatchExecutionId, "dx-omx-1");
assert.equal(execution.stdinPayload.groupProjectId, "group-project-1");
assert.equal(execution.stdinPayload.targetProjectId, "thread-project-1");
assert.equal(execution.stdinPayload.targetThreadId, "thread-1");
assert.equal(execution.stdinPayload.targetThreadDisplayName, "前端主线程");
assert.equal(execution.stdinPayload.objective, "请执行群聊任务");
assert.equal(execution.stdinPayload.workersRequested, 1);
assert.equal(execution.stdinPayload.context.requestedBy, "kris");
});
test("non dispatch or non omx tasks do not use OMX runtime", () => {
assert.equal(
shouldUseOmxTeamTaskRunner({
taskType: "dispatch_execution",
orchestrationBackendId: "boss-native-orchestrator",
}),
false,
);
assert.equal(
shouldUseOmxTeamTaskRunner({
taskType: "conversation_reply",
orchestrationBackendId: "omx-team",
}),
false,
);
});
test("OMX task result parser maps completed payload into dispatch completion fields", () => {
const parsed = parseOmxTeamTaskResult(
JSON.stringify({
status: "completed",
backendId: "omx-team",
requestId: "mastertask-omx-1",
dispatchExecutionId: "dx-omx-1",
rawThreadReply: "线程原始回复",
replyBody: "主 Agent 汇总",
}),
);
assert.equal(parsed.status, "completed");
assert.equal(parsed.requestId, "mastertask-omx-1");
assert.equal(parsed.dispatchExecutionId, "dx-omx-1");
assert.equal(parsed.rawThreadReply, "线程原始回复");
assert.equal(parsed.replyBody, "主 Agent 汇总");
});
test("OMX task result parser surfaces failures", () => {
const parsed = parseOmxTeamTaskResult(
JSON.stringify({
status: "failed",
backendId: "omx-team",
requestId: "mastertask-omx-1",
error: "OMX_EXECUTION_FAILED: test",
}),
);
assert.equal(parsed.status, "failed");
assert.equal(parsed.requestId, "mastertask-omx-1");
assert.equal(parsed.errorMessage, "OMX_EXECUTION_FAILED: test");
});

View File

@@ -35,9 +35,13 @@ function runSmoke(payload: unknown) {
});
}
test("omx team smoke script emits ready JSON for valid payload", async () => {
test("omx team smoke script emits completed JSON for valid dispatch execution payload", async () => {
const result = await runSmoke({
requestKind: "dispatch_execution",
requestId: "mastertask-omx-1",
dispatchExecutionId: "dx-omx-1",
targetProjectId: "thread-project-1",
targetThreadId: "thread-1",
workersRequested: 2,
objective: "并行协作链路 smoke",
});
@@ -45,9 +49,11 @@ test("omx team smoke script emits ready JSON for valid payload", async () => {
assert.equal(result.exitCode, 0);
assert.equal(result.stderr, "");
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.status, "ready");
assert.equal(parsed.status, "completed");
assert.equal(parsed.backendId, "omx-team");
assert.match(parsed.summary, /并行协作链路 smoke/);
assert.equal(parsed.requestId, "mastertask-omx-1");
assert.equal(parsed.dispatchExecutionId, "dx-omx-1");
assert.match(parsed.rawThreadReply, /并行协作链路 smoke/);
});
test("omx team smoke script emits failed JSON for invalid payload", async () => {