feat: add controlled codex thread rollback

This commit is contained in:
AI Bot
2026-06-03 13:30:24 +08:00
parent 13201e6aee
commit 7a30c2a8d9
11 changed files with 421 additions and 2 deletions

View File

@@ -165,6 +165,7 @@
- 第二十七批另补 `streamDeltaEventSummary` 流式增量事件能力摘要:设备详情页会显示 agent delta、plan delta、reasoning delta、MCP progress、command output、terminal interaction 和 file output 等能力分组;该字段只读,不保存原始增量文本、命令输出、推理正文或文件输出。
- 当前任务执行态也已补 `executionProgress.streamEvents`App Server runner 会把 agent / plan / reasoning / MCP / command / terminal / file 的流式 delta 归一成计数Android 进度卡展示“流式增量”,不保存或渲染原始 delta、命令输出、终端输入、推理正文或文件输出。
- 当前 App Server 任务取消已从“服务端标记”升级为“真实 turn 中断”:`POST /api/v1/master-agent/tasks/[taskId]/cancel` 仍负责把任务置为 `canceled`,新增 `GET /api/v1/master-agent/tasks/[taskId]/control-state` 供设备端轮询;`local-agent` 在 App Server turn 启动后会按取消状态调用 `turn/interrupt`,并把 `interrupted` 作为干净取消处理,避免取消后长任务继续跑或被误写成失败日志。
- 当前已补 Codex App Server 受控线程回滚:`POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务,`local-agent` 调用 `thread/rollback` 回滚目标线程最近 N 轮;该链路不启动新 turn不把 thread/turn/items 原文写回 APP只提示“线程历史已回滚”且不会自动还原本地文件变更。
- 当前 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` 完成回写已补幂等,重复完成不会再向群聊重复追加结果

View File

@@ -134,6 +134,7 @@
- App Server heartbeat discovery 现在支持 `skills/extraRoots/set`:配置 `codexAppServerSkillExtraRoots` 或环境变量 `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS`runner 会先把共享 Skill 根下发给 App Server再刷新 `skills/list`,并写入 `capabilities.codexAppServer.metadata.skillExtraRootsSummary`。该字段用于 APP/后台展示企业共享 Skill 根是否已下发只保留数量、basename 和状态不保存根目录绝对路径、Skill 文件路径或配置原文。
- App Server heartbeat discovery 现在支持 `hooks/list`,写入 `capabilities.codexAppServer.metadata.hookSummary`。该字段用于 APP/后台展示本机 Codex hook 治理状态;只保留 workspace 数、hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
- 当前 Codex App Server runner 已新增第一版 Boss Inter-Thread Broker任务携带 `intentCategory=thread_collaboration``sourceCodexThreadRef``targetCodexThreadRef` 时,会先 `thread/read` 源线程,再通过 `thread/inject_items` 向目标线程注入受控摘要,最后 `turn/start` 目标线程;服务端入口是 `POST /api/v1/projects/[projectId]/thread-collaboration`,负责权限、源/目标线程校验和任务排队。这不是假设官方线程 P2P而是 Boss 自己做线程协作编排。
- 当前 Codex App Server runner 已新增受控线程回滚:任务携带 `intentCategory=thread_rollback`、目标 `codexThreadRef``rollbackNumTurns` 时,会调用 `thread/rollback` 回滚目标线程最近 N 轮,不会启动新 turn也不会把 App Server 返回的 thread/turn/items 写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-rollback`,只保存回滚轮数、原因和执行摘要;边界是只回滚 Codex 线程历史,不自动还原本地文件变更。
- 当前 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`
@@ -143,6 +144,7 @@
- 相关配置项:
- `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs`
- `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs`
- `codexComputerUseEnabled / codexComputerUseCommand / codexComputerUseArgs / codexComputerUseWorkdir / codexComputerUseTimeoutMs / codexComputerUseFallbackToCua`
- `codexAppServerEnabled / codexAppServerCommand / codexAppServerArgs / codexAppServerWorkdir / codexAppServerTimeoutMs / codexAppServerFallbackToCli / codexAppServerTransport / codexAppServerUrl / codexAppServerAuthTokenFile / codexAppServerSkillExtraRoots / codexAppServerDiscoveryEnabled / codexAppServerDiscoveryTtlMs / codexAppServerDiscoveryLimit`
- `scripts/codex-app-server-protocol-snapshot.mjs`:生成本机 Codex App Server help、JSON Schema、TypeScript bindings、协议方法清单和 support matrix当前快照目录为 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`
@@ -151,6 +153,7 @@
- 用途:设备端在执行中实时刷新同一张 `execution_progress`
- 权限:设备 token / 设备写鉴权
- 请求体:`deviceId`、可选 `status=queued|running`、可选 `requestId`、可选 `executionProgress`
- 当前行为:只更新任务进度卡和实时事件,不把任务置为 completed / failed最终成功或失败仍必须走 `POST /api/v1/master-agent/tasks/[taskId]/complete`
#### `POST /api/v1/projects/[projectId]/thread-collaboration`
@@ -158,8 +161,15 @@
- 权限:登录态;源项目和目标项目都需要 `project.view`,源项目需要 `master_agent.ask`
- 请求体:`targetProjectId``body``requestText`
- 行为:先在源项目追加用户消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_collaboration`、源/目标 `threadId``codexThreadRef` 和目标 `codexFolderRef`
- 当前行为:只更新任务进度卡和实时事件,不把任务置为 completed / failed最终成功或失败仍必须走 `POST /api/v1/master-agent/tasks/[taskId]/complete`
- `codexComputerUseEnabled / codexComputerUseCommand / codexComputerUseArgs / codexComputerUseWorkdir / codexComputerUseTimeoutMs / codexComputerUseFallbackToCua`
#### `POST /api/v1/projects/[projectId]/thread-rollback`
- 用途:对当前会话绑定的 Codex 线程发起一次受控历史回滚,适合误触发、错误继续、接管误操作后的线程级撤回
- 权限:登录态;目标项目需要 `project.view``master_agent.ask`
- 请求体:`numTurns`,可选 `reason`
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_rollback`、目标 `threadId``codexThreadRef``codexFolderRef``rollbackNumTurns``rollbackReason`
- 边界:设备端通过 Codex App Server 调用 `thread/rollback`,只回滚线程历史;不会自动还原本地文件变更,也不会把 App Server 返回的 thread/turn/items 明文写回 APP
- 当前仓库已自带 browser smoke runtime、desktop Cua runtime 和旧 desktop smoke 兜底:
- `scripts/browser-control-smoke.mjs`
- `scripts/codex-computer-use-runtime.mjs`

View File

@@ -148,6 +148,7 @@ UI 参考:
- 新增服务端线程协作入口 `POST /api/v1/projects/[projectId]/thread-collaboration`,由 Boss 校验源/目标项目权限并创建 `intentCategory=thread_collaboration``conversation_reply` 任务;设备端继续通过 App Server runner 执行 `thread/read -> thread/inject_items -> turn/start`,避免把“线程互通”误做成无监管 P2P
- 新增活跃 turn 干预:任务携带 `targetCodexTurnId` / `targetTurnId`App Server runner 会调用 `turn/steer`,并把 `turnControl=steer``turnId` 写回执行结果;没有活跃 turn id 时仍使用 `turn/start`
- 新增活跃 turn 中断:用户或管理员通过任务取消接口把任务转为 `canceled` 后,设备端会轮询 `GET /api/v1/master-agent/tasks/[taskId]/control-state`;如果当前任务已经启动 App Server turnrunner 会在同一个 JSON-RPC 连接上调用 `turn/interrupt`,并把返回的 `interrupted` 处理成干净取消,不再把用户主动取消误判成 runtime failure
- 新增受控线程回滚:服务端入口 `POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务;设备端通过 App Server 调用 `thread/rollback`,只返回“已回滚最近 N 轮”的用户可见摘要,不启动新 turn不保存 App Server 返回的 thread/turn/items。该能力只回滚 Codex 线程历史,不自动还原本地文件变更,后续如需文件级撤回必须另走 Git / 文件快照恢复链路。
- `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

@@ -263,6 +263,7 @@ cd /Users/kris/code/boss
- 当前已绑定真实 `codexThreadRef` 的普通单线程聊天,会在 `local-agent` 执行 `codex exec resume` 前,先把 Boss 用户消息镜像写入对应 Codex Desktop rollout这样 APP 发起的消息也能进入桌面版同一线程历史,并按 `sourceMessageId` 去重。rollout 定位优先使用 `state_5.sqlite`,状态库不可用或索引缺失时回退扫描 `~/.codex/sessions`;写入后会尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,再通过 `codex://threads/{threadId}` 深链提示桌面版打开目标线程
- 当前 `local-agent` 已新增 `Codex App Server` providerboss-agent 默认配置 `codexAppServerEnabled=true``conversation_reply / dispatch_execution` 会先通过 `codex app-server` 的 stdio JSON-RPC 恢复或创建线程,也可配置 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:<port>``codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Server长驻连接可通过 `codexAppServerAuthTokenFile``BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE` 提供 bearer token。随后 runner 下发 `turn/start` 并收集流式 agent 回复;如果单个 JSON-RPC 请求返回 `-32001 / retry later`runner 会先做指数退避重试;如果任务携带 `targetCodexTurnId`,会改用 `turn/steer` 干预活跃 turn如果 App Server 在 turn 启动前失败,默认允许回退到 `codex exec resume`,如果 turn 已经启动则不再回退,避免同一轮用户消息被重复执行。桌面控制另有 `codexComputerUseEnabled=true`,默认先走 Codex Computer Use再回退 CUA Driver。
- 当前已新增 Boss 自有 Inter-Thread Broker 第一版:服务端入口 `POST /api/v1/projects/[projectId]/thread-collaboration` 会创建带源/目标 Codex 线程引用的协作任务App Server runner 执行 `thread/read(source) -> thread/inject_items(target) -> turn/start(target)`,用于让一个线程的结论受控进入另一个线程,不依赖官方任意线程 P2P 互聊能力
- 当前已新增 Codex App Server 受控线程回滚:服务端入口 `POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务App Server runner 执行 `thread/rollback(target, numTurns)`,只回写“已回滚最近 N 轮”的用户可见摘要,不启动新 turn不保存 App Server 返回的 thread/turn/items。该能力只回滚 Codex 线程历史,不自动还原本地文件变更。
- 当前 `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

@@ -62,6 +62,15 @@ function resolvePrompt(task) {
return String(task?.executionPrompt || task?.requestText || "").trim();
}
function isThreadRollbackTask(task) {
return task?.intentCategory === "thread_rollback" || task?.taskType === "thread_rollback";
}
function resolveRollbackNumTurns(task) {
const numeric = Number(task?.rollbackNumTurns ?? 1);
return Number.isFinite(numeric) && numeric >= 1 ? Math.floor(numeric) : undefined;
}
function normalizeTimeoutMs(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 120_000;
@@ -3088,6 +3097,28 @@ export async function executeCodexAppServerTask(runnerConfig, task) {
if (!threadId) {
throw new Error("CODEX_APP_SERVER_THREAD_ID_MISSING");
}
if (isThreadRollbackTask(task)) {
const numTurns = resolveRollbackNumTurns(task);
if (!numTurns) {
throw new Error("CODEX_APP_SERVER_ROLLBACK_NUM_TURNS_INVALID");
}
await request("thread/rollback", { threadId, numTurns });
return {
status: "completed",
replyBody: `已回滚 Codex 线程最近 ${numTurns} 轮。注意:这只回滚线程历史,不会自动还原本地文件变更。`,
threadId,
turnControl: "rollback",
rollback: {
numTurns,
},
cwd,
transport: runnerConfig.transport,
executionProgress: progressCollector.snapshot(),
canFallbackToCli: false,
};
}
const interThreadBroker = await maybeInjectInterThreadContext({
request,
task,

View File

@@ -0,0 +1,100 @@
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 {
queueThreadRollbackTask,
ThreadConversationExecutionConflictError,
} from "@/lib/boss-master-agent";
function forbiddenResponse(message = "FORBIDDEN") {
return NextResponse.json({ ok: false, message }, { status: 403 });
}
function normalizeRollbackNumTurns(value: unknown) {
const numeric = Number(value ?? 1);
if (!Number.isFinite(numeric) || numeric < 1) {
return undefined;
}
return Math.floor(numeric);
}
function normalizeRollbackReason(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 {
numTurns?: unknown;
reason?: unknown;
};
const numTurns = normalizeRollbackNumTurns(body.numTurns);
if (!numTurns) {
return NextResponse.json(
{ ok: false, message: "NUM_TURNS_MUST_BE_POSITIVE" },
{ status: 400 },
);
}
const reason = normalizeRollbackReason(body.reason);
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 线程最近 ${numTurns} 轮。`,
kind: "text",
});
const task = await queueThreadRollbackTask({
projectId,
requestMessageId: message.id,
numTurns,
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 },
);
}
}

View File

@@ -483,6 +483,7 @@ export type ComputerControlIntentCategory =
| "discussion_only"
| "project_development"
| "thread_collaboration"
| "thread_rollback"
| "browser_control"
| "desktop_control";
export type ComputerControlRuntimeKind =
@@ -1345,6 +1346,8 @@ export interface MasterAgentTask {
projectUnderstandingNotifyOnCompletion?: boolean;
relayViaMasterAgent?: boolean;
mirrorBossUserMessageToCodexDesktop?: boolean;
rollbackNumTurns?: number;
rollbackReason?: string;
intentCategory?: ComputerControlIntentCategory;
runtimeKind?: ComputerControlRuntimeKind;
controlPlatform?: ComputerControlPlatform;
@@ -4727,10 +4730,16 @@ export function migrateBossState(raw: Partial<BossState> | undefined): BossState
relayViaMasterAgent: task.relayViaMasterAgent === true ? true : undefined,
mirrorBossUserMessageToCodexDesktop:
task.mirrorBossUserMessageToCodexDesktop === true ? true : undefined,
rollbackNumTurns:
Number.isFinite(Number(task.rollbackNumTurns)) && Number(task.rollbackNumTurns) >= 1
? Math.floor(Number(task.rollbackNumTurns))
: undefined,
rollbackReason: trimToDefined(task.rollbackReason),
intentCategory:
task.intentCategory === "discussion_only" ||
task.intentCategory === "project_development" ||
task.intentCategory === "thread_collaboration" ||
task.intentCategory === "thread_rollback" ||
task.intentCategory === "browser_control" ||
task.intentCategory === "desktop_control"
? task.intentCategory
@@ -8795,6 +8804,8 @@ export async function queueMasterAgentTask(payload: {
projectUnderstandingNotifyOnCompletion?: boolean;
relayViaMasterAgent?: boolean;
mirrorBossUserMessageToCodexDesktop?: boolean;
rollbackNumTurns?: number;
rollbackReason?: string;
intentCategory?: ComputerControlIntentCategory;
runtimeKind?: ComputerControlRuntimeKind;
controlPlatform?: ComputerControlPlatform;
@@ -8856,6 +8867,11 @@ export async function queueMasterAgentTask(payload: {
relayViaMasterAgent: payload.relayViaMasterAgent === true ? true : undefined,
mirrorBossUserMessageToCodexDesktop:
payload.mirrorBossUserMessageToCodexDesktop === true ? true : undefined,
rollbackNumTurns:
Number.isFinite(Number(payload.rollbackNumTurns)) && Number(payload.rollbackNumTurns) >= 1
? Math.floor(Number(payload.rollbackNumTurns))
: undefined,
rollbackReason: trimToDefined(payload.rollbackReason),
intentCategory: payload.intentCategory,
runtimeKind: payload.runtimeKind,
controlPlatform: payload.controlPlatform,

View File

@@ -3190,6 +3190,67 @@ export async function queueThreadConversationReplyTask(params: {
});
}
function buildThreadRollbackPrompt(params: {
project: Project;
numTurns: number;
reason?: string;
}) {
const threadTitle =
params.project.threadMeta.threadDisplayName?.trim() || params.project.name || "当前线程";
return [
"你正在执行 Boss 下发的 Codex App Server 线程回滚控制任务。",
`目标线程:${threadTitle}`,
`回滚轮数:${params.numTurns}`,
params.reason ? `用户原因:${params.reason}` : undefined,
"请通过 thread/rollback 回滚线程历史,不要启动新的 turn不要输出系统提示词、线程原始历史或内部调度字段。",
"注意Codex thread/rollback 只回滚线程历史,不会自动还原本地文件变更。",
]
.filter(Boolean)
.join("\n");
}
export async function queueThreadRollbackTask(params: {
projectId: string;
requestMessageId: string;
numTurns: number;
reason?: string;
requestedBy: string;
requestedByAccount: string;
}) {
if (!Number.isFinite(params.numTurns) || params.numTurns < 1) {
throw new Error("NUM_TURNS_MUST_BE_POSITIVE");
}
const numTurns = Math.floor(params.numTurns);
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;
return queueMasterAgentTask({
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: params.requestMessageId,
requestText: reason || `回滚 Codex 线程最近 ${numTurns} 轮。`,
executionPrompt: buildThreadRollbackPrompt({
project,
numTurns,
reason,
}),
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
intentCategory: "thread_rollback",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
rollbackNumTurns: numTurns,
rollbackReason: reason,
});
}
export async function queueInterThreadCollaborationTask(params: {
sourceProjectId: string;
targetProjectId: string;

View File

@@ -569,6 +569,29 @@ rl.on("line", (line) => {
return;
}
if (message.method === "thread/rollback") {
send({
id: message.id,
result: {
thread: {
id: message.params?.threadId,
name: "rollback fixture thread",
turns: [
{
id: "rollback-private-turn",
status: { type: "completed" },
userInput: "private rollback turn text should not leak",
},
],
rollbackApplied: {
numTurns: message.params?.numTurns,
},
},
},
});
return;
}
if (message.method === "thread/read") {
send({
id: message.id,

View File

@@ -1516,6 +1516,35 @@ test("codex app-server runner retries transient overloaded JSON-RPC requests", a
}
});
test("codex app-server runner rolls back a thread without starting a new turn or leaking history", 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-rollback",
taskType: "conversation_reply",
intentCategory: "thread_rollback",
targetCodexThreadRef: "019d-app-server-thread",
targetCodexFolderRef: repoRoot,
rollbackNumTurns: 2,
executionPrompt: "回滚最近 2 轮 Codex 线程历史。",
});
assert.equal(result.status, "completed");
assert.equal(result.threadId, "019d-app-server-thread");
assert.equal(result.turnControl, "rollback");
assert.equal(result.rollback?.numTurns, 2);
assert.match(result.replyBody, /已回滚 Codex 线程最近 2 轮/);
assert.match(result.replyBody, /不会自动还原本地文件变更/);
assert.doesNotMatch(result.replyBody, /private rollback turn text/);
assert.equal(result.turnId, undefined);
});
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,146 @@
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-rollback/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-rollback-"));
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-rollback/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-rollback`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify(body),
});
}
function buildThreadProject() {
return {
id: "rollback-project",
name: "可回滚线程",
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "",
updatedAt: "2026-06-03T10:00:00+08:00",
lastMessageAt: "2026-06-03T10:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: "rollback-project",
threadId: "rollback-thread",
threadDisplayName: "可回滚线程",
folderName: "boss",
codexFolderRef: "/Users/kris/code/boss",
codexThreadRef: "codex-rollback-thread",
updatedAt: "2026-06-03T10:00: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-rollback queues a controlled Codex thread rollback 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, {
numTurns: 2,
reason: "撤回刚才误触发的两轮对话。",
}),
{ 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_rollback");
assert.equal(payload.task.rollbackNumTurns, 2);
assert.equal(payload.task.rollbackReason, "撤回刚才误触发的两轮对话。");
assert.equal(payload.task.targetProjectId, project.id);
assert.equal(payload.task.targetCodexThreadRef, "codex-rollback-thread");
assert.equal(payload.task.targetCodexFolderRef, "/Users/kris/code/boss");
const persisted = (await readState()).masterAgentTasks.find(
(task) => task.taskId === payload.task.taskId,
);
assert.equal(persisted?.status, "queued");
assert.equal(persisted?.intentCategory, "thread_rollback");
assert.equal(persisted?.rollbackNumTurns, 2);
});
test("POST /thread-rollback rejects zero-turn rollback", 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, { numTurns: 0 }),
{ params: Promise.resolve({ projectId: project.id }) },
);
const payload = await response.json();
assert.equal(response.status, 400);
assert.equal(payload.ok, false);
assert.equal(payload.message, "NUM_TURNS_MUST_BE_POSITIVE");
});