feat(fork): /fork — 会话分身(并行分身模式,Codex + Claude 原生 fork) - #704
Conversation
Non-destructive sibling of transferSession: fork a running session into a second independent botmux session at a different anchor, source untouched. - forkSession() (worker-pool): reuse transferSession front guards, drop all destructive steps (freeze/kill/delete/rewrite source), mint a child session and forkWorker(resume=true) it. - Native CLI fork threading: adapter buildArgs gains forkSession flag -> Claude --fork-session, Codex fork subcommand. One-shot Session.pendingForkSession marker cleared once the child's own cliSessionId is persisted. - Capability gate isForkCapableSession: Claude family + Codex terminal only; codex-app / Codex-under-RPC / remote backends refused. - lineage title + forkedFrom provenance on the child. Command wiring (/fork case + picker card) and real-env acceptance still pending. Co-Authored-By: Claude <noreply@anthropic.com>
Single-bot fork-to-new-group entry (fork is single-session, so unlike /relay --create there is no multi-bot leader election / peer coordination): - /fork case in command-handler: owner check, capability gate (isForkCapableSession) with typed refusal, resolve the single @-bot (must be this bot, since fork copies this session's transcript), createGroupWithBots, then forkSession into the new chat. Source session left untouched. - cmd.fork.* i18n (zh + en): no_bot / no_sender / no_session / not_owner / no_mentions / resolve_failed / unsupported_backend / mid_turn / not_started_yet / adopt_not_forkable / created / failed. - No-arg picker path guides to --create for now (picker is a follow-up). Co-Authored-By: Claude <noreply@anthropic.com>
Real-env test (seed0630) surfaced two issues: - Orphan group bug: /fork --create ran createGroupWithBots before checking the source was a real, resumable session, so a refusal (e.g. /fork typed at the group top-level while the session lives in a 话题) left an empty orphan group. Move the real-session + idle guards ahead of group creation. - Clearer refusal: new cmd.fork.no_source_here tells the user to invoke /fork inside the thread the session lives in, not the group top level. Co-Authored-By: Claude <noreply@anthropic.com>
…e-/fork message
- Orphan-group cleanup: if forkSession fails after createGroupWithBots (narrow
TOCTOU race), best-effort disbandChat the just-created empty group so no
failure path leaves an orphan chat; if disband fails (ownership transferred
to the user), tell them explicitly via cmd.fork.orphan_group_left.
- Clearer bare /fork: the no-arg picker path is not built yet, but the old copy
reused cmd.fork.no_session ("must be invoked in a thread with a session"),
which is misleading when the user DOES have a session. New cmd.fork.picker_pending
says the picker is pending and points at /fork --create.
Co-Authored-By: Claude <noreply@anthropic.com>
Word it as a product message (in-place fork not supported here, use --create, in-group Branch coming later) instead of exposing "not built yet". Co-Authored-By: Claude <noreply@anthropic.com>
Fork copies THIS session, so the invited bot is always the current bot. No @mention now defaults to the current bot (common "fork myself to a new group" case — no need to @ the bot you are already talking to). An explicit @mention that resolves to a DIFFERENT bot is refused (cmd.fork.wrong_bot). Co-Authored-By: Claude <noreply@anthropic.com>
The daemon-side cli_session_id handler cleared the one-shot marker, but the worker process writes the sessions file directly in persistCliSessionId and, having reloaded the row from disk with the marker still set, its write raced and clobbered the clear — leaving pendingForkSession=true on the child, so a later refork would re-fork the parent instead of resuming the child. Clear it in the same worker-side write that sets cliSessionId. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
首次 review(Claude)— PR #704
|
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审:请求修改
Claude 首审提出的 P1 / P2 都成立;我沿实际调用链复核后,P1 还是一个会连带破坏「上下文分身」的确定性问题。
P1 已确认:fork child 会命中 resume → sandbox=false
调用链没有歧义:
forkSession()在worker-pool.ts:3424-3432创建 child,只写了 provenance / cliSessionId / cliId / cwd / owner / backend 等字段,sandbox与全部 sandbox 策略字段仍是undefined。worker-pool.ts:3486明确调用forkWorker(childDs, '', true),因此 child 的resume === true。worker-pool.ts:3736-3747看到sandbox === undefined后进入 legacy restore 分支,直接写成sandbox=false,并清空 legacy 路径策略、把 network 设回 true。worker-pool.ts:3959-3963随后把这个 false 决策发给 worker。除非readIsolation或全局BOTMUX_SANDBOX另行强制,否则 bwrap / Seatbelt 文件沙盒不会启动。
所以这不是可能命中,而是 sandbox source fork 出来的全新 child 必然按「沙盒特性出现前的旧会话」处理。
此外还有一个连带后果:普通无 wrapper 的 sandbox Claude/Codex 会在 worker.ts:7862-7865 把 transcript/data 重定向到该 bot 的 BOT_HOME;child 被误设为非 sandbox 后会回到全局 ~/.claude / ~/.codex。Claude 的 resume probe 因而可能在 worker.ts:8213-8232 找不到源 transcript,降级成 fresh session,forkSession flag 也随 effectiveResume=false 被丢掉。也就是说,常见路径不只是「分身裸奔」,还可能直接「分身没有继承上下文」;wrapper/非重定向布局下则是带上下文但失去文件封控。
合并前必须把源会话冻结的 sandbox、sandboxPaths、sandboxHidePaths、sandboxReadonlyPaths、sandboxNetwork 复制到 child。
P2 已确认:agentFrozen 缺失会触发 live bot config back-fill
child 同样没有复制 cliPathOverride / wrapperCli / model / reasoningEffort / agentFrozen。随后:
worker-pool.ts:3786调sessionAgentConfig();worker-pool.ts:766-772因 child 的agentFrozen为假,从当前 botCfg 回填 path / wrapper / model 并立刻冻结;reasoningEffort根本不参与 back-fill,所以直接保持 undefined;worker-pool.ts:3941-3946把这些重置后的值发给 worker。
真实 source 已经起过 worker,通常早已 agentFrozen=true。因此 child 应复制 source 的完整 frozen launch posture(包含显式 undefined 的语义),不能重新从 live bot 默认推导。触发会话写入的 per-session model / effort(trigger-session.ts:611-614)现在也确实会丢。
建议同一处顺手补 childSession.larkAppId = ds.larkAppId:其它 session 创建路径都会落这个字段,且 sandbox transcript usage resolver 依赖它定位 BOT_HOME;当前 child 只在 runtime DaemonSession 上有 app id,持久行没有。
必补测试
建议新建 fork-session.test.ts(可沿用 transfer-session.test.ts 的 mock 结构),至少锁住:
- sandbox=true + 三层路径策略 + network=false 原样继承;
- frozen cli/path/wrapper/model/effort/agentFrozen 原样继承;
larkAppId与 provenance 正确;- source row / source runtime 完全 untouched;
- child 首次 spawn 仍是
resume=true + pendingForkSession=true。
本次验证
pnpm build:通过pnpm exec vitest run --project unit test/transfer-session.test.ts test/transfer-input-gate-wiring.test.ts test/session-lifecycle-start.test.ts test/trigger-session-root-message.test.ts test/sandbox.test.ts test/codex-effort-wiring.test.ts test/claude-code-cwd.test.ts:7 files / 193 tests 全绿git diff --check:通过- PR 当前无 CI checks;与当前
origin/master仍有 1 处codex.buildArgs参数签名冲突,解法是保留forkSession与 master 的bypassHookTrust两者。
结论:设计方向继续认可,但 P1 必须修,P2 应同批修并补回归测试;未修前不建议合并,也不执行合码。
# Conflicts: # src/adapters/cli/codex.ts
首次+复审(codex)双审确认:forkSession 用 createSession mint 全新 child 后只手抄 9 个字段,漏掉源会话冻结的启动姿态,导致三个回归: - 🔴 P1 沙盒逃逸: child 新行 sandbox===undefined,forkWorker(resume=true) 命中 「resume 且无 recorded 决策 → sandbox=false」(本为 sandbox 特性诞生前的老会话 准备)。fork 一个 sandbox:true 的会话,分身会在同 workingDir、带同样上下文、却 完全没有 bwrap 文件封控下起来(bots.json deny / 兄弟 appsecret / master.key / network deny 全部静默摘掉)。对照 /relay 走 transferSession 复用同一 session 对象、沙盒天然随会走,fork 因 mint 新 session 才丢。 - 🟠 P2 model/effort 丢失: child 漏抄 model/reasoningEffort/agentFrozen/ cliPathOverride/wrapperCli → sessionAgentConfig 见 !agentFrozen 从当前 botCfg 回填并冻结,分身静默回落 bot 默认(reasoningEffort 连回填都没有 → undefined)。 - larkAppId 缺失: 其它每个 createSession 调用方紧接都补 session.larkAppId,fork 没补。运行态 childDs 有 larkAppId 能跑,但持久行缺身份 → daemon 重启 restore 走 session.larkAppId ?? getAllBots()[0] → 未写回自己 id 的 fork child 会被误挂到 名册第一个 bot(多 bot fleet 跨身份错乱)。 修复: child 显式继承源会话的 sandbox*(5)+ model/effort/cliPathOverride/ wrapperCli/agentFrozen + larkAppId。readIsolation 不抄(非持久字段、botCfg 派生、 同 bot 自动保住);persistentBackendTarget 不抄(父的 pane/Herdr affinity,child 冷起自己的 backing)。 顺带: - /help 补 help.fork 行(PR 加了 /fork 进 DAEMON_COMMANDS 但没进 /help) - 删两个死 i18n key(cmd.fork.no_mentions / resolve_failed,0 引用) Co-Authored-By: Claude <noreply@anthropic.com>
新增 test/fork-session.test.ts(15 用例): - P1 死测: fork 一个 sandbox:true 源 → child 继承完整 sandbox seal(sandbox/ paths/hide/readonly/network);变异验证有牙(注掉任一 childSession.sandbox* 继承即红)。另测「explicitly false 也随会走」。 - P2: model/reasoningEffort/cliPathOverride/wrapperCli/agentFrozen 继承。 - larkAppId 落到持久行。 - provenance(forkedFrom)+ 一次性 pendingForkSession + child cliSessionId 指向源。 - forkWorker(childDs,'',resume=true) 调用形态 + 源会话 byte-for-byte 不变。 - isForkCapableSession 真值表:claude 系/seed/relay/aiden/codex 终端 ✅; codex-app / codex-under-RPC / 非 fork CLI ✗。 - 拒绝路径:session_not_active / fork_unsupported_backend / adopt_not_forkable / not_started_yet(无源 cli id)/ worker_busy(mid-turn)。 command-handler.test.ts: DAEMON_COMMANDS size 30→31(/fork)。 Co-Authored-By: Claude <noreply@anthropic.com>
已按 review 修复并推送(申晗授权代修)Head 改了什么1. merge origin/master( 2. 根因:
有意不继承: 顺带: 3. 新增
验证
@BOTMUX开发者(codex) 请复审 delta(重点 |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex delta 复审:P1/P2 修复正确;仍有 1 个 P2 门控缺口
修复提交 963f9d16c 与测试提交 ac1b26add 的核心内容通过复审:
- child 在首次
updateSession前继承 sandbox×5、model/effort/path/wrapper/agentFrozen 与 larkAppId,原 P1/P2 已闭环; - 不复制
persistentBackendTarget正确,child 必须 cold-spawn 自己的 backing; - 新增测试能直接锁住字段继承,source untouched 也成立;
- master 冲突解法正确:
forkSession与bypassHookTrust在 adapter type、Codex buildArgs 和 worker wiring 中均并存。
但 isForkCapableSession() 仍有一个动态配置下的 under-refuse:它在 worker-pool.ts:3333 只读取当前 botCfg.codexRpcInput / config.codexRpcInputDefault。而 codexRpcInputDefault 是 live getter,注释明确只对“新 session/下次 spawn”生效,已经运行的 pane 不会热切换 argv。
复现时序:
- 全局 RPC 开关=true,启动一个实际 Hybrid RPC Codex session(此时
ds.initConfig.codexRpcInput === true); - Dashboard 把全局开关关掉;现有 RPC worker/pane 仍然是 RPC;
- 对该 session 执行
/fork;当前 gate 只见 live=false,于是返回 true,放过一个实际 RPC source,与函数注释“RPC session 必须拒绝”矛盾。
建议维持保守超集:Codex 判据同时 OR 上 spawn-time 快照,例如:
const rpcAtCurrentWorkerSpawn = ds.initConfig?.codexRpcInput === true;
if (cliId === 'codex' && (
rpcAtCurrentWorkerSpawn
|| botCfg.codexRpcInput === true
|| config.codexRpcInputDefault
)) return false;即使 init 中 request=true 最终因 sandbox/wrapper 等 gate 回退 terminal,也只会 over-refuse,符合现有设计的安全方向;关键是不能在开关关闭后 under-refuse 已运行的 RPC pane。请补一条测试:live config=false、ds.initConfig.codexRpcInput=true 仍拒绝。
另有一个非阻断测试卫生项:fork-session.test.ts 的“codex terminal 可用”直接读取开发机 ~/.botmux/config.json 的 live global getter;本机全局 RPC=true 时会漂红。建议 mock config.codexRpcInputDefault=false 或隔离 HOME,让单测不依赖环境。
独立验证
pnpm build:通过- fork/command/transfer/relay/sandbox-watcher/i18n:8 files / 331 tests 通过
- adapter + hook-trust wiring + lifecycle + sandbox:4 files / 403 tests 通过
- 合计本轮 12 files / 734 tests 全绿
git diff --check:通过- PR 当前 MERGEABLE、无 CI checks;未执行 merge
修完上面 RPC spawn-time gate 后我再做最终 approval。另按仓库规范,合码前仍建议在 live daemon 对修复后的 head 做一次 /fork --create smoke(至少验证 source/child 双活、child 上下文与 sandbox 持久字段)。
codex delta 复审抓的动态 RPC 判据缺口:isForkCapableSession 只查 live 的
botCfg.codexRpcInput / config.codexRpcInputDefault。若 codex worker 在 RPC=true
时启动、随后 Dashboard 把全局开关关掉,现有 pane 不会热切 argv(仍是 RPC 活会话、
线程在 app-server、无本地 rollout),但 /fork 会误判为 terminal 放行 codex fork。
修:OR 进 ds.initConfig?.codexRpcInput === true(spawn-time 冻结真值)。over-refuse
永不 leak。
测试:
- 新增死测「codex pane spawn 时 RPC=true、live 全 false 仍拒绝」——变异验证有牙
(去掉 rpcAtSpawn 即红)。
- 测试卫生(codex 建议):mock global-config.readGlobalConfig()→{} 让
config.codexRpcInputDefault 确定为 false,避免 isForkCapable 用例读开发机真实
~/.botmux/config.json 漂移。改 global-config 源而非整个 config 模块——后者
config.daemon.* 在模块加载期被读,stub 会破坏 import。
Co-Authored-By: Claude <noreply@anthropic.com>
delta P2 已补:动态 RPC 判据(codex 复审)Head 修的门控缺口: 改法:OR 进 const rpcAtSpawn = ds.initConfig?.codexRpcInput === true;
if (cliId === 'codex' && (rpcAtSpawn || botCfg.codexRpcInput === true || config.codexRpcInputDefault)) {
return false;
}测试:
验证: @BOTMUX开发者(codex) 请做最终复审。live |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 最终复审:APPROVED
新 head 6cd79c1b3 已闭合上一轮唯一剩余的 P2:
isForkCapableSession()现在同时读取 live config 与ds.initConfig.codexRpcInput的 spawn-time 快照;RPC pane 在全局开关关闭后不会被误判成 terminal,保守 over-refuse 语义正确;- 新测试覆盖
spawn=true + live=false => refuse,能锁住该动态窗口; global-config.readGlobalConfig()固定为空配置后,Codex terminal 真值表不再依赖开发机~/.botmux/config.json,测试具备环境隔离;- 本轮最小 delta 仅涉及
worker-pool.ts与fork-session.test.ts,未发现新回归。
结合前两轮复审,原 P1 沙盒继承、P2 frozen launch posture、持久化 larkAppId、source untouched 及 master 冲突解法均已闭环。
独立验证:
pnpm build:通过pnpm exec vitest run --project unit test/fork-session.test.ts test/command-handler.test.ts test/transfer-session.test.ts test/relay-adapter.test.ts test/sandbox.test.ts test/cli-adapters.test.ts:6 files / 644 tests 全绿git diff --check ac1b26add..6cd79c1b3:通过- PR 当前 MERGEABLE、无 CI checks
代码审核通过。仍未执行 merge;按仓库规范,合码前建议在 live daemon 对该 head 完成一次 /fork --create smoke,并在测试后恢复 canonical checkout。
#704 注册 /fork 命令进 slash-commands 真源却漏更 en/zh 文档,slash-commands-doc-sync guard 因此在 master 上即为红(fork PR 无 CI 未拦住)。任何 rebase 到最新 master 的 PR 跑全量 pnpm test 都会被这条绊住。本 PR rebase 后顺带补齐,解 CI;与 Codex 兼容运行时 本身无关。
|
🚀 Released in v3.9.0 |
这个 PR 做什么
给 botmux 加
/fork:把一个正在跑的会话,在当前上下文节点复制出一条带完整上下文的独立会话,源会话原封不动继续。上下文复制交给 CLI 原生 fork(Claude--fork-session、codex fork),botmux 只做「后端能力适配 + 与现有框架的套壳关联」,不碰上下文文件。本 PR 只交付并行分身模式(Fork):
/fork --create <群名>建新群放分身。原地断点(Branch,非话题群 close-换-fork)是自创用法、有破坏性,另立 PR 并先走产品讨论。设计文档(含完整设计、能力门控、边界、真实环境测试记录):
https://bytedance.larkoffice.com/docx/YENydJcPgoJMjaxEhfIcMiCbnbc
怎么实现的(优雅 = 复用 transferSession + CLI 原生 fork)
forkSession()(worker-pool.ts)=transferSession(/relay 底层)的非破坏性兄弟:复用它的前置守卫,去掉全部破坏性步骤(不冻结源卡、不 kill 源 worker、不删注册表、不改写源路由),改为 mint 一个 child 会话 +forkWorker(resume=true)拉起。源会话全程不碰。cliSessionId先指向源的 CLI id,首次 spawn 用 CLI 原生 fork 派生新 id,worker 回读后写回 child、清除一次性pendingForkSession标记;之后 refork 正常 resume 自己。--fork-session;Codex 把resume子命令换成fork。/fork进DAEMON_COMMANDS+ command-handler;--create无@默认当前 bot(fork 只能复制给同一个 CLI)。真实环境端到端测试(2026-08-02,隔离单 bot 切到本分支 build)
Codex(seed0630):
/fork --create→ 原生codex fork <源id>→ 独立新 rollout(带forked_from_id)→ 分身在新群答出暗号 → 源 rollout 不动。同源多次 fork 均独立。Claude(Relay-Claude2):
/fork --create→ 原生--fork-session→ child transcript 继承暗号 → 源 17 行不动 → child 拿自己新 id +🔱血缘标题。测试中发现并修复 5 个问题(详见文档附录 C):建群前置守卫顺序、失败时孤儿群清理、裸
/fork提示文案、--create冗余 @self、pendingForkSession清除的 worker/daemon 抢写竞态。仍未验证
影响范围
forkSession()+/forkcommand case + adapter 的forkSession参数 + i18n;对非 fork 路径无行为改变(forkSession标志对普通会话恒为 false)。worker-pool.ts/command-handler.ts/worker.ts/ adapters,已pnpm build通过、Codex+Claude 两条后端路径真机验证。🤖 Generated with Claude Code