diff --git a/README.md b/README.md index a9d8fb7..c573f94 100644 --- a/README.md +++ b/README.md @@ -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 `,只有缺失真实线程引用时才退回 `--ephemeral` +- `local-agent` 对 `conversation_reply` 当前会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--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 响应 diff --git a/android/app/build.gradle b/android/app/build.gradle index 427a125..71b3b2b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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" } diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 23ff0da..79dcaba 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -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 `,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral` +- `conversation_reply` 当前会优先走 `codex exec resume `,把任务恢复到真实 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` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index ff88387..76a86ce 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -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 `,只有缺失真实线程引用时才退回 `--ephemeral` +- 当前 `local-agent` 对 `conversation_reply` 任务会优先使用 `codex exec resume `,只有缺失真实线程引用时才退回 `--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,避免控制面短时阻塞时本地健康探针不可用 diff --git a/local-agent/config.cloud.json b/local-agent/config.cloud.json index 81a6b8a..e1499d5 100644 --- a/local-agent/config.cloud.json +++ b/local-agent/config.cloud.json @@ -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": "", diff --git a/local-agent/config.example.json b/local-agent/config.example.json index 7f22a6b..cbeed87 100644 --- a/local-agent/config.example.json +++ b/local-agent/config.example.json @@ -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": "", diff --git a/local-agent/omx-team-task-runner.mjs b/local-agent/omx-team-task-runner.mjs new file mode 100644 index 0000000..455d500 --- /dev/null +++ b/local-agent/omx-team-task-runner.mjs @@ -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(); + }); +} diff --git a/local-agent/server.mjs b/local-agent/server.mjs index c0230de..506f3c6 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -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, diff --git a/scripts/omx-team-smoke.mjs b/scripts/omx-team-smoke.mjs index b26027b..8671fd2 100644 --- a/scripts/omx-team-smoke.mjs +++ b/scripts/omx-team-smoke.mjs @@ -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})`, }); diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 408c8ad..52db2f8 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -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") { diff --git a/tests/local-agent-omx-task-runner.test.mjs b/tests/local-agent-omx-task-runner.test.mjs new file mode 100644 index 0000000..d43e122 --- /dev/null +++ b/tests/local-agent-omx-task-runner.test.mjs @@ -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"); +}); diff --git a/tests/omx-team-smoke-script.test.ts b/tests/omx-team-smoke-script.test.ts index 2431c3e..f1c77f0 100644 --- a/tests/omx-team-smoke-script.test.ts +++ b/tests/omx-team-smoke-script.test.ts @@ -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 () => {