feat: fork codex threads
This commit is contained in:
@@ -170,6 +170,7 @@
|
||||
- 当前已补 Codex App Server 受控线程归档 / 恢复:`POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务,`local-agent` 直接调用 `thread/archive` 或 `thread/unarchive`;该链路不启动普通 turn,不把 thread 原始字段写回 APP,只提示“线程已归档/已恢复”。
|
||||
- 当前已补 Codex App Server 受控线程改名:`POST /api/v1/projects/[projectId]/rename` 在 `mode=thread` 且绑定真实 `codexThreadRef` 时,会在本地 Boss 改名后创建 `intentCategory=thread_rename` 任务,`local-agent` 直接调用 `thread/name/set`;该链路不启动普通 turn,不把 thread 原始字段写回 APP,只提示“已同步 Codex 线程名称”。设备离线、并发冲突或 App Server 不可用不会回滚 Boss 本地改名。
|
||||
- 当前已补 Codex App Server 受控线程 Git 元数据同步:`POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务,`local-agent` 直接调用 `thread/metadata/update`;当前只允许同步 `gitInfo.sha / branch / originUrl`,不会启动普通 turn,也不允许写入任意 metadata。
|
||||
- 当前已补 Codex App Server 受控线程分叉:`POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务,`local-agent` 直接调用 `thread/fork`;当前不允许远程覆盖 model、sandbox、instructions 或 config,也不会把 path、cwd、turns、instructionSources 写回 APP。新线程进入 Boss 会话列表仍依赖 thread discovery / 导入链路。
|
||||
- 当前 boss-agent 已支持 Mac OTA:`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。
|
||||
- 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA;`MasterAgentTask` claim 会记录 attempt 和 lease,运行中任务可按租约重试,超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。
|
||||
- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果
|
||||
@@ -255,7 +256,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 / git metadata sync;其中项目目标新增会在单线程且已绑定 `codexThreadRef` 时异步创建 `thread_goal_sync` 任务并调用 `thread/goal/set`,Git 元数据同步通过 `thread_metadata_sync -> thread/metadata/update` 执行;这些都不是普通对话 turn,也不代表文件变更或发布完成
|
||||
- Codex App Server 受控线程治理已接入 rollback / compact / archive / unarchive / rename / goal sync / git metadata sync / fork;其中项目目标新增会在单线程且已绑定 `codexThreadRef` 时异步创建 `thread_goal_sync` 任务并调用 `thread/goal/set`,Git 元数据同步通过 `thread_metadata_sync -> thread/metadata/update` 执行,线程分叉通过 `thread_fork -> thread/fork` 执行;这些都不是普通对话 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,尚未接更多对象存储或更丰富的附件详情页
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
- 当前 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`。
|
||||
- 当前 Codex App Server runner 已新增受控线程 Git 元数据同步:任务携带 `intentCategory=thread_metadata_sync`、目标 `codexThreadRef` 和 `threadMetadataGitInfo` 时,会直接调用 `thread/metadata/update`,不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-metadata`;当前只允许 patch `gitInfo.sha / branch / originUrl`,不开放任意 metadata 写入。
|
||||
- 当前 Codex App Server runner 已新增受控线程分叉:任务携带 `intentCategory=thread_fork`、目标 `codexThreadRef` 和 `threadForkEphemeral` 时,会直接调用 `thread/fork`,不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-fork`;当前只使用源 thread id 分叉,不允许远程覆盖 model、sandbox、instructions 或 config,新 Codex 线程进入 Boss 会话列表仍依赖后续 discovery / 导入链路。
|
||||
- 当前 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`
|
||||
@@ -199,6 +200,14 @@
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_metadata_sync`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`threadMetadataGitInfo` 和 `threadMetadataReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/metadata/update`;不会启动普通 turn,不会把 App Server 返回的 thread 原始字段写回 APP,也不允许写入 Git 信息之外的任意 metadata
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-fork`
|
||||
|
||||
- 用途:对当前会话绑定的 Codex 线程发起受控分叉,适合在不破坏原线程历史的情况下复制当前上下文继续试验
|
||||
- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask`
|
||||
- 请求体:可选 `reason`,可选 `ephemeral`
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_fork`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`threadForkEphemeral` 和 `threadForkReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/fork`;不会启动普通 turn,不会把 App Server 返回的 path、cwd、turns、instructions 写回 APP;当前不允许远程覆盖 model、sandbox、instructions 或 config;新线程进入 Boss 会话列表依赖后续 thread discovery / 导入链路
|
||||
|
||||
- 当前仓库已自带 browser smoke runtime、desktop Cua runtime 和旧 desktop smoke 兜底:
|
||||
- `scripts/browser-control-smoke.mjs`
|
||||
- `scripts/codex-computer-use-runtime.mjs`
|
||||
|
||||
@@ -154,6 +154,7 @@ UI 参考:
|
||||
- 新增受控线程改名:服务端入口复用 `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 线程目标和状态,不代表代码修改、文件恢复或版本发布完成。
|
||||
- 新增受控线程 Git 元数据同步:服务端入口 `POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务;设备端通过 App Server 直接调用 `thread/metadata/update`,不启动普通 turn,不保存 App Server 原始 thread payload。当前只允许 patch `gitInfo.sha / branch / originUrl`,用于同步分支、提交和远端仓库信息。
|
||||
- 新增受控线程分叉:服务端入口 `POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务;设备端通过 App Server 直接调用 `thread/fork`,不启动普通 turn,不保存 App Server 返回的 path、cwd、turns 或 instructionSources。当前只返回新线程 id/name/preview/status/ephemeral,且不允许远程覆盖 model、sandbox、instructions 或 config。
|
||||
- `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,默认仍是 stdio,ws/unix 适合作为同机长驻 App Server 灰度路径
|
||||
- 新增 App Server 过载退避:单个 JSON-RPC 请求收到 `-32001` 或 `retry later` 文案时,会在同一个任务生命周期内重试,超出上限后才进入失败/CLI fallback 判定
|
||||
- 新增 App Server capability discovery:`local-agent` 会把可用模型、默认/快速/深度模型建议、provider 能力、Skill、Plugin、App 摘要写入设备 heartbeat;Web 设备详情已显示 App Server、模型和扩展数量,为后续 APP/后台模型配置页提供真实数据来源
|
||||
|
||||
@@ -269,6 +269,7 @@ cd /Users/kris/code/boss
|
||||
- 当前已新增 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。设备离线或冲突时,本地项目目标仍成功,响应只返回非致命同步错误。
|
||||
- 当前已新增 Codex App Server 受控线程 Git 元数据同步:服务端入口 `POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务;App Server runner 直接执行 `thread/metadata/update(target, gitInfo)`,不启动普通 turn,不保存 App Server 原始 thread payload。当前只允许同步 `gitInfo.sha / branch / originUrl`,用于让 Boss 线程治理和 Codex 线程的分支/提交信息保持一致。
|
||||
- 当前已新增 Codex App Server 受控线程分叉:服务端入口 `POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务;App Server runner 直接执行 `thread/fork(target)`,不启动普通 turn,不保存 App Server 返回的 path、cwd、turns 或 instructionSources。当前不允许远程覆盖 model、sandbox、instructions 或 config;新 Codex 线程进入 Boss 会话列表仍通过现有 thread discovery / 导入链路完成。
|
||||
- 当前 `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 / failed,Android 原生聊天页会显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”。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 治理摘要、运行事件摘要、扩展事件摘要、线程生命周期摘要和流式增量摘要
|
||||
|
||||
@@ -82,6 +82,10 @@ function isThreadMetadataSyncTask(task) {
|
||||
return task?.intentCategory === "thread_metadata_sync" || task?.taskType === "thread_metadata_sync";
|
||||
}
|
||||
|
||||
function isThreadForkTask(task) {
|
||||
return task?.intentCategory === "thread_fork" || task?.taskType === "thread_fork";
|
||||
}
|
||||
|
||||
function resolveThreadRenameName(task) {
|
||||
return trimToDefined(task?.threadRenameName || task?.threadName || task?.name);
|
||||
}
|
||||
@@ -127,6 +131,10 @@ function resolveThreadMetadataGitInfo(task) {
|
||||
return Object.keys(gitInfo).length > 0 ? gitInfo : undefined;
|
||||
}
|
||||
|
||||
function resolveThreadForkEphemeral(task) {
|
||||
return task?.threadForkEphemeral === true || task?.ephemeral === true;
|
||||
}
|
||||
|
||||
function resolveThreadLifecycleAction(task) {
|
||||
if (
|
||||
task?.threadLifecycleAction === "archive" ||
|
||||
@@ -3229,6 +3237,42 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isThreadForkTask(task)) {
|
||||
const forkSourceThreadId = targetThreadRef;
|
||||
const ephemeral = resolveThreadForkEphemeral(task);
|
||||
if (!forkSourceThreadId) {
|
||||
throw new Error("CODEX_APP_SERVER_THREAD_ID_MISSING");
|
||||
}
|
||||
const forkResponse = await request("thread/fork", {
|
||||
threadId: forkSourceThreadId,
|
||||
ephemeral,
|
||||
});
|
||||
const forkedThread = forkResponse?.thread && typeof forkResponse.thread === "object"
|
||||
? forkResponse.thread
|
||||
: {};
|
||||
const forkedThreadId = trimToDefined(forkedThread.id);
|
||||
return {
|
||||
status: "completed",
|
||||
replyBody: forkedThreadId
|
||||
? `已分叉 Codex 线程:${forkedThread.name || forkedThreadId}。`
|
||||
: "已分叉 Codex 线程。",
|
||||
threadId: forkSourceThreadId,
|
||||
turnControl: "fork",
|
||||
threadFork: {
|
||||
sourceThreadId: forkSourceThreadId,
|
||||
forkedThreadId,
|
||||
forkedThreadName: trimToDefined(forkedThread.name),
|
||||
forkedThreadPreview: trimToDefined(forkedThread.preview),
|
||||
ephemeral: forkedThread.ephemeral === true,
|
||||
status: trimToDefined(forkedThread.status),
|
||||
},
|
||||
cwd,
|
||||
transport: runnerConfig.transport,
|
||||
executionProgress: progressCollector.snapshot(),
|
||||
canFallbackToCli: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isThreadRenameTask(task)) {
|
||||
const renameThreadId = targetThreadRef;
|
||||
const name = resolveThreadRenameName(task);
|
||||
|
||||
86
src/app/api/v1/projects/[projectId]/thread-fork/route.ts
Normal file
86
src/app/api/v1/projects/[projectId]/thread-fork/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data";
|
||||
import { canAccessProject } from "@/lib/boss-permissions";
|
||||
import {
|
||||
queueThreadForkTask,
|
||||
ThreadConversationExecutionConflictError,
|
||||
} from "@/lib/boss-master-agent";
|
||||
|
||||
function forbiddenResponse(message = "FORBIDDEN") {
|
||||
return NextResponse.json({ ok: false, message }, { status: 403 });
|
||||
}
|
||||
|
||||
function normalizeReason(value: unknown) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
reason?: unknown;
|
||||
ephemeral?: unknown;
|
||||
};
|
||||
const reason = normalizeReason(body.reason);
|
||||
const ephemeral = body.ephemeral === true;
|
||||
|
||||
const state = await readState();
|
||||
const projectExists = state.projects.some((project) => project.id === projectId);
|
||||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||||
return forbiddenResponse(projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND");
|
||||
}
|
||||
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
|
||||
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: reason || "分叉当前 Codex 线程。",
|
||||
kind: "text",
|
||||
});
|
||||
const task = await queueThreadForkTask({
|
||||
projectId,
|
||||
requestMessageId: message.id,
|
||||
ephemeral,
|
||||
reason,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
const nextState = await readState();
|
||||
const project = nextState.projects.find((item) => item.id === projectId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message,
|
||||
task,
|
||||
collaborationGate: buildCollaborationGate(project),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ThreadConversationExecutionConflictError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
code: error.message,
|
||||
message: "THREAD_EXECUTION_CONFLICT",
|
||||
executionConflict: error.conflict,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -490,6 +490,7 @@ export type ComputerControlIntentCategory =
|
||||
| "thread_rename"
|
||||
| "thread_goal_sync"
|
||||
| "thread_metadata_sync"
|
||||
| "thread_fork"
|
||||
| "browser_control"
|
||||
| "desktop_control";
|
||||
export type ComputerControlRuntimeKind =
|
||||
@@ -1371,6 +1372,8 @@ export interface MasterAgentTask {
|
||||
threadGoalReason?: string;
|
||||
threadMetadataGitInfo?: ThreadMetadataGitInfoPatch;
|
||||
threadMetadataReason?: string;
|
||||
threadForkEphemeral?: boolean;
|
||||
threadForkReason?: string;
|
||||
intentCategory?: ComputerControlIntentCategory;
|
||||
runtimeKind?: ComputerControlRuntimeKind;
|
||||
controlPlatform?: ComputerControlPlatform;
|
||||
@@ -4807,6 +4810,8 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
|
||||
threadGoalReason: trimToDefined(task.threadGoalReason),
|
||||
threadMetadataGitInfo: normalizeThreadMetadataGitInfoPatch(task.threadMetadataGitInfo),
|
||||
threadMetadataReason: trimToDefined(task.threadMetadataReason),
|
||||
threadForkEphemeral: task.threadForkEphemeral === true,
|
||||
threadForkReason: trimToDefined(task.threadForkReason),
|
||||
intentCategory:
|
||||
task.intentCategory === "discussion_only" ||
|
||||
task.intentCategory === "project_development" ||
|
||||
@@ -4818,6 +4823,7 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
|
||||
task.intentCategory === "thread_rename" ||
|
||||
task.intentCategory === "thread_goal_sync" ||
|
||||
task.intentCategory === "thread_metadata_sync" ||
|
||||
task.intentCategory === "thread_fork" ||
|
||||
task.intentCategory === "browser_control" ||
|
||||
task.intentCategory === "desktop_control"
|
||||
? task.intentCategory
|
||||
@@ -8895,6 +8901,8 @@ export async function queueMasterAgentTask(payload: {
|
||||
threadGoalReason?: string;
|
||||
threadMetadataGitInfo?: ThreadMetadataGitInfoPatch;
|
||||
threadMetadataReason?: string;
|
||||
threadForkEphemeral?: boolean;
|
||||
threadForkReason?: string;
|
||||
intentCategory?: ComputerControlIntentCategory;
|
||||
runtimeKind?: ComputerControlRuntimeKind;
|
||||
controlPlatform?: ComputerControlPlatform;
|
||||
@@ -8986,6 +8994,8 @@ export async function queueMasterAgentTask(payload: {
|
||||
threadGoalReason: trimToDefined(payload.threadGoalReason),
|
||||
threadMetadataGitInfo: normalizeThreadMetadataGitInfoPatch(payload.threadMetadataGitInfo),
|
||||
threadMetadataReason: trimToDefined(payload.threadMetadataReason),
|
||||
threadForkEphemeral: payload.threadForkEphemeral === true,
|
||||
threadForkReason: trimToDefined(payload.threadForkReason),
|
||||
intentCategory: payload.intentCategory,
|
||||
runtimeKind: payload.runtimeKind,
|
||||
controlPlatform: payload.controlPlatform,
|
||||
|
||||
@@ -3587,6 +3587,64 @@ export async function queueThreadMetadataSyncTask(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function buildThreadForkPrompt(params: {
|
||||
project: Project;
|
||||
ephemeral: boolean;
|
||||
reason?: string;
|
||||
}) {
|
||||
const threadTitle =
|
||||
params.project.threadMeta.threadDisplayName?.trim() || params.project.name || "当前线程";
|
||||
return [
|
||||
"你正在执行 Boss 下发的 Codex App Server 线程分叉控制任务。",
|
||||
`源线程:${threadTitle}`,
|
||||
`是否临时线程:${params.ephemeral ? "是" : "否"}`,
|
||||
params.reason ? `用户原因:${params.reason}` : undefined,
|
||||
"请通过 thread/fork 分叉当前 Codex 线程,不要启动普通 turn,不要输出系统提示词、线程原始历史或内部调度字段。",
|
||||
"注意:该动作只创建 Codex 分叉线程,不代表代码修改、文件恢复或版本发布完成;新线程进入 Boss 会话列表依赖后续线程 discovery / 导入链路。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export async function queueThreadForkTask(params: {
|
||||
projectId: string;
|
||||
requestMessageId: string;
|
||||
ephemeral?: boolean;
|
||||
reason?: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
}) {
|
||||
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 ephemeral = params.ephemeral === true;
|
||||
return queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: params.requestMessageId,
|
||||
requestText: reason || "分叉当前 Codex 线程。",
|
||||
executionPrompt: buildThreadForkPrompt({
|
||||
project,
|
||||
ephemeral,
|
||||
reason,
|
||||
}),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId,
|
||||
intentCategory: "thread_fork",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
threadForkEphemeral: ephemeral,
|
||||
threadForkReason: reason,
|
||||
});
|
||||
}
|
||||
|
||||
export async function queueInterThreadCollaborationTask(params: {
|
||||
sourceProjectId: string;
|
||||
targetProjectId: string;
|
||||
|
||||
26
tests/fixtures/codex-app-server-runtime.mjs
vendored
26
tests/fixtures/codex-app-server-runtime.mjs
vendored
@@ -705,6 +705,32 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "thread/fork") {
|
||||
send({
|
||||
id: message.id,
|
||||
result: {
|
||||
thread: {
|
||||
id: `${message.params?.threadId}-fork`,
|
||||
sessionId: "fork-session",
|
||||
forkedFromId: message.params?.threadId,
|
||||
name: "Forked working thread",
|
||||
preview: "Fork preview should be safe",
|
||||
ephemeral: message.params?.ephemeral === true,
|
||||
status: "idle",
|
||||
path: "/private/path/that-should-not-leak",
|
||||
cwd: "/private/cwd/that-should-not-leak",
|
||||
turns: [],
|
||||
internalForkSecret: "thread-fork-secret-should-not-leak",
|
||||
},
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
cwd: "/private/cwd/that-should-not-leak",
|
||||
instructionSources: ["/private/AGENTS.md"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === "thread/read") {
|
||||
send({
|
||||
id: message.id,
|
||||
|
||||
@@ -1714,6 +1714,36 @@ test("codex app-server runner syncs thread git metadata without starting a norma
|
||||
assert.doesNotMatch(JSON.stringify(result), /thread-metadata-secret-should-not-leak/);
|
||||
});
|
||||
|
||||
test("codex app-server runner forks a thread 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-fork",
|
||||
taskType: "conversation_reply",
|
||||
intentCategory: "thread_fork",
|
||||
targetCodexThreadRef: "019d-app-server-thread",
|
||||
targetCodexFolderRef: repoRoot,
|
||||
threadForkEphemeral: false,
|
||||
executionPrompt: "分叉当前 Codex 线程。",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.threadId, "019d-app-server-thread");
|
||||
assert.equal(result.turnControl, "fork");
|
||||
assert.equal(result.threadFork?.sourceThreadId, "019d-app-server-thread");
|
||||
assert.equal(result.threadFork?.forkedThreadId, "019d-app-server-thread-fork");
|
||||
assert.equal(result.threadFork?.forkedThreadName, "Forked working thread");
|
||||
assert.equal(result.threadFork?.ephemeral, false);
|
||||
assert.equal(result.turnId, undefined);
|
||||
assert.doesNotMatch(JSON.stringify(result), /thread-fork-secret-should-not-leak/);
|
||||
});
|
||||
|
||||
test("codex app-server runner stays disabled unless feature flag is explicit", () => {
|
||||
const runnerConfig = getCodexAppServerRunnerConfig(process.env, {
|
||||
codexAppServerCommand: process.execPath,
|
||||
|
||||
126
tests/thread-fork-route.test.ts
Normal file
126
tests/thread-fork-route.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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]/thread-fork/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-fork-"));
|
||||
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]/thread-fork/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}/thread-fork`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildThreadProject() {
|
||||
return {
|
||||
id: "fork-project",
|
||||
name: "可分叉线程",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "",
|
||||
updatedAt: "2026-06-03T14:30:00+08:00",
|
||||
lastMessageAt: "2026-06-03T14:30:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "fork-project",
|
||||
threadId: "fork-thread",
|
||||
threadDisplayName: "可分叉线程",
|
||||
folderName: "boss",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "codex-fork-source-thread",
|
||||
updatedAt: "2026-06-03T14: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 /thread-fork queues a controlled Codex thread fork task", 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, {
|
||||
reason: "从当前状态分叉一条验证线程。",
|
||||
ephemeral: false,
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: project.id }) },
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task.intentCategory, "thread_fork");
|
||||
assert.equal(payload.task.threadForkReason, "从当前状态分叉一条验证线程。");
|
||||
assert.equal(payload.task.threadForkEphemeral, false);
|
||||
assert.equal(payload.task.targetProjectId, project.id);
|
||||
assert.equal(payload.task.targetCodexThreadRef, "codex-fork-source-thread");
|
||||
|
||||
const persisted = (await readState()).masterAgentTasks.find(
|
||||
(task) => task.taskId === payload.task.taskId,
|
||||
);
|
||||
assert.equal(persisted?.status, "queued");
|
||||
assert.equal(persisted?.intentCategory, "thread_fork");
|
||||
assert.equal(persisted?.threadForkReason, "从当前状态分叉一条验证线程。");
|
||||
assert.equal(persisted?.threadForkEphemeral, false);
|
||||
});
|
||||
Reference in New Issue
Block a user