feat: sync codex thread goals

This commit is contained in:
AI Bot
2026-06-03 14:21:27 +08:00
parent cc31b0d836
commit 0186ef7057
11 changed files with 397 additions and 2 deletions

View File

@@ -254,6 +254,7 @@ npm run apk:debug
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通;日志检索已有基础分页,风险 SLA 通知账本已接入,外部通知渠道仍未做
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线;主 Agent 理解同步已经避免未接管状态下主动问线程,后续重点是继续细化导入筛选规则和用户主动同步体验
- Codex App Server 受控线程治理已接入 rollback / compact / archive / unarchive / rename / goal sync其中项目目标新增会在单线程且已绑定 `codexThreadRef` 时异步创建 `thread_goal_sync` 任务并调用 `thread/goal/set`,这不是普通对话 turn也不代表文件变更或发布完成
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` schema 校验 / 文件备份 / dry-run 迁移 / PostgreSQL 备份导出 / 备份恢复 / 文件回滚工具但生产仍默认文件状态。PostgreSQL 路径必须显式设置 `BOSS_STATE_STORE=postgres`,真实连接 / 写入还必须设置 `BOSS_DATABASE_URL`。最高管理员后台已新增 `GET/POST /api/v1/admin/backups` 文件状态快照能力,可手动创建、列出和恢复快照,恢复前会自动生成 pre-restore 快照;文件状态写入层已默认开启自动 `auto:writeState` 历史快照
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
- 当前只支持服务器文件存储和阿里 OSS尚未接更多对象存储或更丰富的附件详情页

View File

@@ -138,6 +138,7 @@
- 当前 Codex App Server runner 已新增受控线程压缩:任务携带 `intentCategory=thread_compact` 和目标 `codexThreadRef` 时,会调用 `thread/compact/start` 发起上下文压缩,不会启动普通 turn也不会把 contextCompaction item 的原始字段写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-compact`,只保存压缩原因和执行摘要;边界是只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
- 当前 Codex App Server runner 已新增受控线程归档 / 恢复:任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `codexThreadRef``threadLifecycleAction` 时,会直接调用 `thread/archive``thread/unarchive`,不会先 resume 已归档线程,也不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-archive`,只保存生命周期动作、原因和执行摘要;边界是只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
- 当前 Codex App Server runner 已新增受控线程改名:任务携带 `intentCategory=thread_rename`、目标 `codexThreadRef``threadRenameName` 时,会直接调用 `thread/name/set`,不会先 resume 线程,也不会启动普通 turn。服务端入口复用 `POST /api/v1/projects/[projectId]/rename``mode=thread` 分支;本地 Boss 会话改名先成功,随后异步创建 Codex 改名任务,设备离线或冲突只返回非致命 `codexThreadRenameError`
- 当前 Codex App Server runner 已新增受控线程目标同步:任务携带 `intentCategory=thread_goal_sync`、目标 `codexThreadRef``threadGoalObjective``threadGoalStatus` 时,会直接调用 `thread/goal/set`,不会启动普通 turn。服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标先成功,单线程且已绑定 Codex 线程时再异步创建 Codex goal 同步任务,设备离线或冲突只返回非致命 `codexThreadGoalError`
- 当前 boss-agent Mac OTA 已接入:`local-agent/boss-agent-ota-runner.mjs` 会用设备 token 调 Boss 服务端 `/api/v1/boss-agent/ota` 检查最新 Mac 运行包,`/api/v1/boss-agent/ota/apply` 会下载 `boss-agent-mac-latest.zip`、校验 sha256、暂存安装 wrapper并拉起本机安装器安装脚本会保留绑定配置并只更新版本号与本机 runtime 路径。安装器会优先沿用当前 LaunchAgent active config并保留所有 `config*.json`,避免多电脑场景中误绑定到默认设备配置。当前最新验证包为 `20260516221619`;构建脚本支持 `BOSS_AGENT_NOTARIZE=1` 的 Developer ID 公证路径。
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime
- `local-agent/browser-control-task-runner.mjs`
@@ -1043,6 +1044,14 @@
- 用途:新增项目目标
- 输入:
- `text`
- 当前行为:
- 本地 Boss 项目目标先落盘
- 如果目标项目是已绑定 `codexThreadRef` 的单线程会话,会追加创建 `intentCategory=thread_goal_sync``conversation_reply` 任务,由本机 App Server runner 调用 `thread/goal/set` 同步 Codex 线程 goal
- 设备离线、并发冲突或 App Server 不可用时,本地目标仍保留,响应会带非致命 `codexThreadGoalError`
- 返回:
- `goal`
- `codexThreadGoalTask`(可选)
- `codexThreadGoalError`(可选)
#### `GET /api/v1/threads/[threadId]/context-budget`

View File

@@ -152,6 +152,7 @@ UI 参考:
- 新增受控线程压缩:服务端入口 `POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务;设备端通过 App Server 调用 `thread/compact/start`,只返回“已发起上下文压缩”的用户可见摘要,不启动普通 turn不保存 contextCompaction item 的原始字段。该能力只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
- 新增受控线程归档 / 恢复:服务端入口 `POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务;设备端通过 App Server 直接调用 `thread/archive``thread/unarchive`,不先 resume 已归档线程,不启动普通 turn不保存 App Server 返回的 thread 原始字段。该能力只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
- 新增受控线程改名:服务端入口复用 `POST /api/v1/projects/[projectId]/rename``mode=thread` 分支;本地 Boss 会话标题先更新,再创建 `intentCategory=thread_rename` 任务;设备端通过 App Server 直接调用 `thread/name/set`,不先 resume 线程,不启动普通 turn不保存 App Server 返回的 thread 原始字段。该能力只同步 Codex 线程显示名,不代表代码修改、文件恢复或版本发布完成。
- 新增受控线程目标同步:服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标先更新,再对已绑定 `codexThreadRef` 的单线程创建 `intentCategory=thread_goal_sync` 任务;设备端通过 App Server 直接调用 `thread/goal/set`,不启动普通 turn不保存 App Server 原始 goal payload。该能力只同步 Codex 线程目标和状态,不代表代码修改、文件恢复或版本发布完成。
- `getCodexAppServerRunnerConfig` 已识别 `codexAppServerTransport` / `BOSS_CODEX_APP_SERVER_TRANSPORT``codexAppServerUrl` / `BOSS_CODEX_APP_SERVER_URL``codexAppServerAuthTokenFile` / `BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE``local-agent/codex-app-server-runner.mjs` 现已支持 `stdio``ws://127.0.0.1:<port>``unix://PATH` 三种 JSON-RPC transport默认仍是 stdiows/unix 适合作为同机长驻 App Server 灰度路径
- 新增 App Server 过载退避:单个 JSON-RPC 请求收到 `-32001``retry later` 文案时,会在同一个任务生命周期内重试,超出上限后才进入失败/CLI fallback 判定
- 新增 App Server capability discovery`local-agent` 会把可用模型、默认/快速/深度模型建议、provider 能力、Skill、Plugin、App 摘要写入设备 heartbeatWeb 设备详情已显示 App Server、模型和扩展数量为后续 APP/后台模型配置页提供真实数据来源

View File

@@ -267,6 +267,7 @@ cd /Users/kris/code/boss
- 当前已新增 Codex App Server 受控线程压缩:服务端入口 `POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务App Server runner 执行 `thread/compact/start(target)`,只回写“已发起上下文压缩”的用户可见摘要,不启动普通 turn不保存 contextCompaction item 原始字段。该能力只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
- 当前已新增 Codex App Server 受控线程归档 / 恢复:服务端入口 `POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务App Server runner 直接执行 `thread/archive(target)``thread/unarchive(target)`,不先 resume 已归档线程,不启动普通 turn不保存 App Server 返回的 thread 原始字段。该能力只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
- 当前已新增 Codex App Server 受控线程改名:服务端入口复用 `POST /api/v1/projects/[projectId]/rename``mode=thread` 分支;本地 Boss 线程标题更新后会创建 `intentCategory=thread_rename` 任务App Server runner 直接执行 `thread/name/set(target, name)`,不先 resume 线程,不启动普通 turn不保存 App Server 线程原始字段。设备离线或冲突时,本地改名仍成功,响应只返回非致命同步错误。
- 当前已新增 Codex App Server 受控线程目标同步:服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标更新后,如果该项目是已绑定 `codexThreadRef` 的单线程,会创建 `intentCategory=thread_goal_sync` 任务App Server runner 直接执行 `thread/goal/set(target, objective, status, tokenBudget?)`,不启动普通 turn不保存 App Server 原始 goal payload。设备离线或冲突时本地项目目标仍成功响应只返回非致命同步错误。
- 当前 `local-agent``dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody`
- 当前 `local-agent` 会在 Codex 任务执行中和完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failedAndroid 原生聊天页会显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”。2026-05-31 起Codex App Server 的 `turn/plan/updated``turn/diff/updated``item/started|completed``thread/started` 会直接映射为进度步骤、变更统计、生成产物和后台智能体;第二批已把 `item/*/requestApproval``item/autoApprovalReview/*``guardianWarning``serverRequest/resolved``item/fileChange/patchUpdated` 映射为审批、安全提醒和文件变更摘要;第三批已把 `thread/status/changed``thread/realtime/*` 安全映射为线程状态和实时状态摘要;第四批已把 `model/rerouted``thread/tokenUsage/updated``mcpServer/startupStatus/updated``remoteControl/status/changed` 安全映射为运行状态摘要;第五批已把 `thread/goal/*``thread/settings/updated``thread/compacted` 映射为线程配置摘要;第六批已把 `account/updated``account/rateLimits/updated``model/verification``warning``configWarning``deprecationNotice` 映射为账号状态、模型校验和安全提醒摘要;第七批已把 `ThreadItem.collabToolCall``ThreadItem.contextCompaction` 映射为线程协作和上下文压缩摘要;第八批已把 `mcpToolCall``dynamicToolCall``webSearch``imageView``enteredReviewMode``exitedReviewMode``commandExecution` 映射为工具活动摘要;第九批已把 `ThreadItem.plan``ThreadItem.reasoning.summary` 映射为计划步骤与思考摘要;第十批已把 `ThreadItem.imageGeneration` 映射为图像生成工具活动和图片产物;第十一批已把 `hook/started|completed` 映射为钩子生命周期工具活动;第十二批已把 `windowsSandbox/setupCompleted` 映射为 Windows 沙箱准备状态摘要;第十七批已把新版 `ThreadItem.collabToolCall.receiverThreadIds / agentsStates` 安全映射为线程协作目标数量和 agent 状态集合。所有进度均通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;字段白名单会剥离 cwd、turnId、配置文件路径、内部 prompt、collab 源/目标线程 ID、receiverThreadIds、agentsStates 私有消息、共享 Skill 根绝对路径、tool arguments/result、web URL token、命令正文/输出、raw reasoning content、reasoning item id、图像生成 revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径和未清洗密钥complete 回写仍会携带最终进度兜底
- 当前 `local-agent` heartbeat 已新增 Codex App Server capability discovery按 TTL 拉取模型、provider 能力、Skill、Hook、Plugin、App 摘要,并附加只读线程操作、插件治理、账号治理、配置治理、文件治理、命令会话、外部 Agent 迁移、Marketplace、实验特性、审查、Windows 沙箱、文件搜索事件、MCP、用户交互、Guardian、运行事件、扩展事件、线程生命周期和流式增量能力 catalog写入 `capabilities.codexAppServer.metadata`Web 设备详情会展示 App Server 连接状态、模型数量、默认/快速/深度模型、扩展数量、Hook 治理摘要、线程操作摘要、插件治理摘要、账号治理摘要、配置治理摘要、文件治理摘要、命令会话摘要、迁移治理摘要、市场治理摘要、实验特性治理摘要、审查治理摘要、Windows 沙箱摘要、文件搜索事件摘要、MCP 治理摘要、用户交互摘要、Guardian 治理摘要、运行事件摘要、扩展事件摘要、线程生命周期摘要和流式增量摘要

View File

@@ -74,10 +74,35 @@ function isThreadRenameTask(task) {
return task?.intentCategory === "thread_rename" || task?.taskType === "thread_rename";
}
function isThreadGoalSyncTask(task) {
return task?.intentCategory === "thread_goal_sync" || task?.taskType === "thread_goal_sync";
}
function resolveThreadRenameName(task) {
return trimToDefined(task?.threadRenameName || task?.threadName || task?.name);
}
function resolveThreadGoalObjective(task) {
return trimToDefined(task?.threadGoalObjective || task?.goalObjective || task?.objective);
}
function resolveThreadGoalStatus(task) {
const status = trimToDefined(task?.threadGoalStatus || task?.goalStatus);
return status === "active" ||
status === "paused" ||
status === "blocked" ||
status === "usageLimited" ||
status === "budgetLimited" ||
status === "complete"
? status
: "active";
}
function resolveThreadGoalTokenBudget(task) {
const numeric = Number(task?.threadGoalTokenBudget ?? task?.goalTokenBudget);
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : undefined;
}
function resolveThreadLifecycleAction(task) {
if (
task?.threadLifecycleAction === "archive" ||
@@ -3118,6 +3143,40 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
});
notify("initialized", {});
if (isThreadGoalSyncTask(task)) {
const goalThreadId = targetThreadRef;
const objective = resolveThreadGoalObjective(task);
const status = resolveThreadGoalStatus(task);
const tokenBudget = resolveThreadGoalTokenBudget(task);
if (!goalThreadId) {
throw new Error("CODEX_APP_SERVER_THREAD_ID_MISSING");
}
if (!objective) {
throw new Error("CODEX_APP_SERVER_THREAD_GOAL_OBJECTIVE_MISSING");
}
await request("thread/goal/set", {
threadId: goalThreadId,
objective,
status,
tokenBudget,
});
return {
status: "completed",
replyBody: `已同步 Codex 线程目标:${objective}`,
threadId: goalThreadId,
turnControl: "goal_sync",
threadGoal: {
objective,
status,
...(tokenBudget ? { tokenBudget } : {}),
},
cwd,
transport: runnerConfig.transport,
executionProgress: progressCollector.snapshot(),
canFallbackToCli: false,
};
}
if (isThreadRenameTask(task)) {
const renameThreadId = targetThreadRef;
const name = resolveThreadRenameName(task);

View File

@@ -1,6 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { createGoal } from "@/lib/boss-data";
import { createGoal, readState } from "@/lib/boss-data";
import {
ThreadConversationExecutionConflictError,
queueThreadGoalSyncTask,
} from "@/lib/boss-master-agent";
export async function POST(
request: NextRequest,
@@ -19,7 +23,37 @@ export async function POST(
try {
const goal = await createGoal(projectId, body.text);
return NextResponse.json({ ok: true, goal });
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
let codexThreadGoalTask = null;
let codexThreadGoalError = null;
if (project && !project.isGroup && project.threadMeta?.codexThreadRef?.trim()) {
try {
codexThreadGoalTask = await queueThreadGoalSyncTask({
projectId,
requestMessageId: `thread-goal-sync:${projectId}:${goal.id}`,
objective: goal.text,
status: goal.state === "completed" ? "complete" : "active",
requestedBy: session.displayName || session.account,
requestedByAccount: session.account,
});
} catch (error) {
codexThreadGoalError =
error instanceof ThreadConversationExecutionConflictError
? "THREAD_EXECUTION_CONFLICT"
: error instanceof Error
? error.message
: "CODEX_THREAD_GOAL_SYNC_FAILED";
}
}
return NextResponse.json({
ok: true,
goal,
...(codexThreadGoalTask ? { codexThreadGoalTask } : {}),
...(codexThreadGoalError ? { codexThreadGoalError } : {}),
});
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },

View File

@@ -488,6 +488,7 @@ export type ComputerControlIntentCategory =
| "thread_archive"
| "thread_unarchive"
| "thread_rename"
| "thread_goal_sync"
| "browser_control"
| "desktop_control";
export type ComputerControlRuntimeKind =
@@ -1357,6 +1358,10 @@ export interface MasterAgentTask {
threadLifecycleReason?: string;
threadRenameName?: string;
threadRenameReason?: string;
threadGoalObjective?: string;
threadGoalStatus?: "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
threadGoalTokenBudget?: number;
threadGoalReason?: string;
intentCategory?: ComputerControlIntentCategory;
runtimeKind?: ComputerControlRuntimeKind;
controlPlatform?: ComputerControlPlatform;
@@ -4752,6 +4757,21 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
threadLifecycleReason: trimToDefined(task.threadLifecycleReason),
threadRenameName: trimToDefined(task.threadRenameName),
threadRenameReason: trimToDefined(task.threadRenameReason),
threadGoalObjective: trimToDefined(task.threadGoalObjective),
threadGoalStatus:
task.threadGoalStatus === "active" ||
task.threadGoalStatus === "paused" ||
task.threadGoalStatus === "blocked" ||
task.threadGoalStatus === "usageLimited" ||
task.threadGoalStatus === "budgetLimited" ||
task.threadGoalStatus === "complete"
? task.threadGoalStatus
: undefined,
threadGoalTokenBudget:
Number.isFinite(Number(task.threadGoalTokenBudget)) && Number(task.threadGoalTokenBudget) > 0
? Math.floor(Number(task.threadGoalTokenBudget))
: undefined,
threadGoalReason: trimToDefined(task.threadGoalReason),
intentCategory:
task.intentCategory === "discussion_only" ||
task.intentCategory === "project_development" ||
@@ -4761,6 +4781,7 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
task.intentCategory === "thread_archive" ||
task.intentCategory === "thread_unarchive" ||
task.intentCategory === "thread_rename" ||
task.intentCategory === "thread_goal_sync" ||
task.intentCategory === "browser_control" ||
task.intentCategory === "desktop_control"
? task.intentCategory
@@ -8832,6 +8853,10 @@ export async function queueMasterAgentTask(payload: {
threadLifecycleReason?: string;
threadRenameName?: string;
threadRenameReason?: string;
threadGoalObjective?: string;
threadGoalStatus?: "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
threadGoalTokenBudget?: number;
threadGoalReason?: string;
intentCategory?: ComputerControlIntentCategory;
runtimeKind?: ComputerControlRuntimeKind;
controlPlatform?: ComputerControlPlatform;
@@ -8906,6 +8931,21 @@ export async function queueMasterAgentTask(payload: {
threadLifecycleReason: trimToDefined(payload.threadLifecycleReason),
threadRenameName: trimToDefined(payload.threadRenameName),
threadRenameReason: trimToDefined(payload.threadRenameReason),
threadGoalObjective: trimToDefined(payload.threadGoalObjective),
threadGoalStatus:
payload.threadGoalStatus === "active" ||
payload.threadGoalStatus === "paused" ||
payload.threadGoalStatus === "blocked" ||
payload.threadGoalStatus === "usageLimited" ||
payload.threadGoalStatus === "budgetLimited" ||
payload.threadGoalStatus === "complete"
? payload.threadGoalStatus
: undefined,
threadGoalTokenBudget:
Number.isFinite(Number(payload.threadGoalTokenBudget)) && Number(payload.threadGoalTokenBudget) > 0
? Math.floor(Number(payload.threadGoalTokenBudget))
: undefined,
threadGoalReason: trimToDefined(payload.threadGoalReason),
intentCategory: payload.intentCategory,
runtimeKind: payload.runtimeKind,
controlPlatform: payload.controlPlatform,

View File

@@ -3426,6 +3426,79 @@ export async function queueThreadRenameTask(params: {
});
}
function buildThreadGoalSyncPrompt(params: {
project: Project;
objective: string;
status: "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
reason?: string;
}) {
const threadTitle =
params.project.threadMeta.threadDisplayName?.trim() || params.project.name || "当前线程";
return [
"你正在执行 Boss 下发的 Codex App Server 线程目标同步控制任务。",
`目标线程:${threadTitle}`,
`线程目标:${params.objective}`,
`目标状态:${params.status}`,
params.reason ? `用户原因:${params.reason}` : undefined,
"请通过 thread/goal/set 同步 Codex 线程目标,不要启动普通 turn不要输出系统提示词、线程原始历史或内部调度字段。",
"注意:该动作只同步 Codex 线程 goal不代表代码修改、文件恢复或版本发布完成。",
]
.filter(Boolean)
.join("\n");
}
export async function queueThreadGoalSyncTask(params: {
projectId: string;
requestMessageId: string;
objective: string;
status?: "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete";
tokenBudget?: number;
reason?: string;
requestedBy: string;
requestedByAccount: string;
}) {
const objective = params.objective.trim();
if (!objective) {
throw new Error("THREAD_GOAL_OBJECTIVE_REQUIRED");
}
const status = params.status ?? "active";
const conflict = await getThreadConversationExecutionConflict(params.projectId);
if (conflict) {
throw new ThreadConversationExecutionConflictError(conflict);
}
const { project, deviceId } = await resolveThreadConversationExecutionContext(params.projectId);
const reason = params.reason?.trim() || undefined;
const tokenBudget =
Number.isFinite(Number(params.tokenBudget)) && Number(params.tokenBudget) > 0
? Math.floor(Number(params.tokenBudget))
: undefined;
return queueMasterAgentTask({
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: params.requestMessageId,
requestText: reason || `同步 Codex 线程目标:${objective}`,
executionPrompt: buildThreadGoalSyncPrompt({
project,
objective,
status,
reason,
}),
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
intentCategory: "thread_goal_sync",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
threadGoalObjective: objective,
threadGoalStatus: status,
threadGoalTokenBudget: tokenBudget,
threadGoalReason: reason,
});
}
export async function queueInterThreadCollaborationTask(params: {
sourceProjectId: string;
targetProjectId: string;

View File

@@ -671,6 +671,26 @@ rl.on("line", (line) => {
return;
}
if (message.method === "thread/goal/set") {
send({
id: message.id,
result: {},
});
send({
method: "thread/goal/updated",
params: {
threadId: message.params?.threadId,
goal: {
objective: message.params?.objective,
status: message.params?.status,
tokenBudget: message.params?.tokenBudget,
internalGoalSecret: "thread-goal-secret-should-not-leak",
},
},
});
return;
}
if (message.method === "thread/read") {
send({
id: message.id,

View File

@@ -1646,6 +1646,38 @@ test("codex app-server runner renames a thread without starting a normal turn",
assert.doesNotMatch(JSON.stringify(result), /thread-name-secret-should-not-leak/);
});
test("codex app-server runner syncs a thread goal without starting a normal turn", async () => {
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
codexAppServerEnabled: true,
codexAppServerCommand: process.execPath,
codexAppServerArgs: ["tests/fixtures/codex-app-server-runtime.mjs"],
codexAppServerWorkdir: repoRoot,
codexAppServerTimeoutMs: 5000,
});
const result = await executeCodexAppServerTask(runnerConfig, {
taskId: "task-thread-goal-sync",
taskType: "conversation_reply",
intentCategory: "thread_goal_sync",
targetCodexThreadRef: "019d-app-server-thread",
targetCodexFolderRef: repoRoot,
threadGoalObjective: "完成 App Server 线程目标双向同步。",
threadGoalStatus: "active",
threadGoalTokenBudget: 120000,
executionPrompt: "同步 Codex 线程目标。",
});
assert.equal(result.status, "completed");
assert.equal(result.threadId, "019d-app-server-thread");
assert.equal(result.turnControl, "goal_sync");
assert.equal(result.threadGoal?.objective, "完成 App Server 线程目标双向同步。");
assert.equal(result.threadGoal?.status, "active");
assert.equal(result.threadGoal?.tokenBudget, 120000);
assert.match(result.replyBody, /已同步 Codex 线程目标/);
assert.equal(result.turnId, undefined);
assert.doesNotMatch(JSON.stringify(result), /thread-goal-secret-should-not-leak/);
});
test("codex app-server runner stays disabled unless feature flag is explicit", () => {
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
codexAppServerCommand: process.execPath,

View File

@@ -0,0 +1,125 @@
import test from "node:test";
import assert from "node:assert/strict";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
let postRoute: (typeof import("../src/app/api/v1/projects/[projectId]/goals/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-thread-goal-sync-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [route, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/goals/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
postRoute = route.POST;
createAuthSession = data.createAuthSession;
readState = data.readState;
writeState = data.writeState;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
await rm(runtimeRoot, { recursive: true, force: true });
});
async function createAuthedRequest(projectId: string, body: unknown) {
const session = await createAuthSession({
account: "krisolo",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/goals`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify(body),
});
}
function buildThreadProject() {
return {
id: "goal-sync-project",
name: "目标同步线程",
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "",
updatedAt: "2026-06-03T12:30:00+08:00",
lastMessageAt: "2026-06-03T12:30:00+08:00",
isGroup: false,
threadMeta: {
projectId: "goal-sync-project",
threadId: "goal-sync-thread",
threadDisplayName: "目标同步线程",
folderName: "boss",
codexFolderRef: "/Users/kris/code/boss",
codexThreadRef: "codex-goal-sync-thread",
updatedAt: "2026-06-03T12:30:00+08:00",
},
groupMembers: [],
createdByAgent: false,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [],
goals: [],
versions: [],
};
}
test("POST /goals creates a Boss goal and queues Codex thread goal sync", async () => {
const state = await readState();
const project = buildThreadProject();
state.projects = [
...state.projects.filter((item) => item.id === "master-agent"),
project,
];
await writeState(state);
const response = await postRoute(
await createAuthedRequest(project.id, {
text: "完成 App Server 线程目标双向同步。",
}),
{ params: Promise.resolve({ projectId: project.id }) },
);
const payload = await response.json();
assert.equal(response.status, 200);
assert.equal(payload.ok, true);
assert.equal(payload.goal.text, "完成 App Server 线程目标双向同步。");
assert.equal(payload.codexThreadGoalTask.intentCategory, "thread_goal_sync");
assert.equal(payload.codexThreadGoalTask.threadGoalObjective, "完成 App Server 线程目标双向同步。");
assert.equal(payload.codexThreadGoalTask.threadGoalStatus, "active");
assert.equal(payload.codexThreadGoalTask.targetCodexThreadRef, "codex-goal-sync-thread");
const persisted = (await readState()).masterAgentTasks.find(
(task) => task.taskId === payload.codexThreadGoalTask.taskId,
);
assert.equal(persisted?.status, "queued");
assert.equal(persisted?.intentCategory, "thread_goal_sync");
assert.equal(persisted?.threadGoalObjective, "完成 App Server 线程目标双向同步。");
assert.equal(persisted?.threadGoalStatus, "active");
});