
切换项目后,Agent 为什么还在改旧目录?一条执行链怎样安全换根
界面已经从项目 A 切到 B,AI 智能体却仍把补丁写进 A;测试在 B 运行,构建产物甚至可能继续污染 A。每个局部步骤都显示成功,最终交付却来自两个项目。问题不在模型记错名字,而在后台存在多个互相矛盾的“当前项目”。
安全切换不是改一个下拉框,而是给整条执行链有序换根:先停止旧项目继续产生副作用,再保存新根,用同一绑定计划重建运行程序、工具进程、文件监听器和智能体工作目录,最后核对任务与证据都落在新根。本文给出这条顺序、Windows 上容易漏掉的句柄与路径陷阱,以及一份切换验收清单。
可以把它想成施工队换工地:负责人已经把牌子从 A 工地换成 B,但工人仍在 A 拆墙,工具车开到了 B,监控探头继续盯着 A,验收员却在 B 的日记本上记录进度。每个人都能说“我按地址工作了”,整支队伍交出的却是一笔无法验收的工程。所谓安全换根,就是让整个班组同时停下旧工作、领取同一份新地址,再证明所有人确实已经到达新工地。
“当前项目”不是一个字符串
一个本地 Agent(人工智能体)系统至少会在七处使用项目根。先用一张角色表看清它们为什么会不同步:
| 技术组件 | 白话角色 | 拿错地址会发生什么 |
|---|---|---|
| Runtime(运行程序) | 现场调度员 | 继续按旧项目配置启动会话 |
| MCP Server(工具服务进程) | 工具车 | 命令和文件工具仍从旧目录工作 |
| Watcher(文件监听器) | 监控探头 | 继续把旧目录变化当成当前事件 |
Agent 终端与子进程的 cwd(当前工作目录) | 真正干活的工人 | 测试、构建和写文件仍落到旧项目 |
| 任务准入与提交根 | 派工台 | 新任务被写进错误项目的队列 |
| FCoP(文件协作协议)生命周期根 | 施工台账 | 任务、报告和审查记录发生串账 |
| 日志与证据根 | 验收档案柜 | 旧项目的执行证据被归到新项目 |
如果七处只切换六处,系统仍可能制造一条外表完整、实际串项目的记录。例如:Agent 在旧目录修改代码,测试命令从新目录启动,REPORT(执行报告)又引用新项目任务。每个局部动作都“成功”,组合结果却无法验收。
把治理药方记成四句话即可:**先停手拔插头 → 再锁定新地址 → 给全班组发同一份新图纸 → 验明任务与证据同根后再开工。**后文的代码和测试都在回答这四步分别怎样成立、哪里还没有成立。
Node.js 的子进程文档说明了最底层的一条事实:子进程拥有明确的 cwd(当前工作目录),未指定时会继承父进程当前工作目录;指定目录不存在时会产生 ENOENT(路径不存在错误)。因此,“父应用当前显示项目 B”不能自动改变已经启动的子进程。
VS Code 的多根工作区文档也提醒扩展开发者:未适配多个文件夹的扩展可能仍只对第一个文件夹工作。Workspace Trust则把新增文件夹的信任判断单独处理。工作区包含某个目录、扩展以哪个目录工作、目录是否受信,是三种不同状态。
第一道原则:只有一个活动项目根
CodeFlowMu 的 project-registry.ts 保存活动项目,并生成一份运行绑定计划。该计划要求 Runtime、MCP、监听器和 Cursor Agent 工作目录使用同一个根。诊断函数会比较预期根、Runtime 实例根、写入锁根和各组件绑定,发现不一致时返回 ACTIVE_PROJECT_BINDING_MISMATCH。
这比让每个组件自己调用一次“查当前项目”更安全。后者存在时间差:组件甲在切换前读到 A,组件乙在切换后读到 B,两者都认为自己拿到了“当前值”。
更稳妥的做法是先生成一次绑定计划,然后把同一份不可变结果传给全部组件:
active project root = D:/work/project-b
Runtime root ─┐
MCP root ─┤
Watcher root ─┼─ 必须全部等于 project-b
Agent cwd ─┤
Writer lock ─┘这里的“同一”不是路径字符串看起来相似,而应经过路径规范化;在 Windows 上还要处理大小写、反斜杠和 fcop/ 子目录被误当成业务根等问题。
Symlink(符号链接)又增加一层陷阱:它就像给同一个房间创建了一个快捷入口,路径名字不同,推开门却是同一个物理目录。D:/work/app 和 D:/links/app 因此可能指向同一处,pnpm 等工具也会大量使用链接。当前受测实现主要依靠绝对路径解析和业务根归一化,本文没有找到它用 fs.realpathSync.native()(读取操作系统真实路径)统一消除全部链接别名的证据。因此,生产级根身份最好同时保存“用户选择的逻辑路径”和“操作系统解析后的真实路径”;安全比较使用真实路径,界面仍可展示逻辑路径。目标不存在时无法取真实路径,应在切换前直接拒绝,而不是退回字符串猜测。
第二道原则:一次请求不能中途重新猜根
进程级活动项目确定以后,每次任务请求还需要自己的稳定上下文。否则,一个长请求执行到一半时项目发生切换,前半段可能读取 A,后半段却把证据写到 B。
ProjectExecutionContext 在请求边界创建 Immutable Context(不可变执行上下文):一项任务开工后,所用项目地址就像塑封的施工图,不能在中途被全局切换偷偷改写。这个对象绑定:
- 业务项目根;
- Runtime 实例身份;
- 数据根;
- 任务准入根;
- 任务提交根;
- 生命周期根;
- 证据根。
下游组件接收这份上下文,而不是各自重新发现项目。若调用方传入的预期根不同,系统会抛出 PROJECT_EXECUTION_CONTEXT_MISMATCH。
它解决的是请求内部的一致性,不是整个热切换。旧请求仍可能持有旧上下文,所以切换前必须先处理活动会话。
正确顺序:先停旧工作,再建立新世界
CodeFlowMu 当前公开的项目切换路径位于 web-panel.ts。在启用项目切换重载时,它的顺序可以概括为:
1. 校验目标项目存在且不是受保护的安装源码根
2. 取消所有活动 Agent 会话
3. 如果仍有会话无法停止,返回冲突;不切换
4. 停止旧 Runtime
5. 保存新的 activeProjectId 和项目根
6. 应用新的项目作用域配置
7. 清理新根的账本缓存并重新读取
8. 调度 Shell/Runtime 重载
9. 前端等待健康检查报告的新 projectRoot
图 1:执行链换根时序。来源:本文根据 CodeFlowMu 固定提交整理。实线主流程来自当前公开实现;虚线框中的排空、Windows 句柄探测和真实路径核对是本文建议,不冒充现成功能。
这个顺序的核心不是“刷新页面”,而是关闭旧项目继续产生副作用的通道。若第 2 步失败,继续保存新根只会制造两个同时有效的世界。
需要特别收紧一个词:这不是数据库意义上的原子事务。代码会在保存新根失败时恢复先前活动项目标识,并尝试重新调度 Runtime;但它不能撤销切换前已经发送到外部服务的请求,也不能证明任意第三方工具没有在内存里缓存旧目录。
因此,更准确的表述是:有序停止、持久化、重建与核对,并带有限回滚。
六类失败必须单独处理
1. 目标目录已经消失
项目注册表仍有 B,但磁盘目录被移动或删除。当前实现会拒绝切换;启动解析也会回退到可用实例根并写出诊断,而不是继续使用不存在的目录。
风险在于“回退”可能让用户误以为仍在 B。界面必须同时显示有效根和回退原因,不能只显示项目名称。
2. 旧会话无法停止
某个 Agent 仍在等待模型流式返回、运行构建,或执行长耗时工具。当前切换接口调用紧急取消;只要仍有会话无法停止,就返回冲突并保留旧项目。这是正确的失败关闭,但它不是完整的优雅排空机制。
更成熟的目标顺序是:先关闭新任务入口,再向在途会话发协作式取消,给文件写入和子进程一个有界退出窗口;窗口结束后重新核对会话、子进程和写入租约。超时不能直接假装切换成功。只有确认进程属于当前运行时、且已经记录“可能留下半成品”的脏状态后,才能把强制终止作为最后手段,并在新项目恢复前要求修复或隔离旧现场。
如果业务必须支持并行项目,更合理的结构是创建两个独立 Runtime 实例,而不是让一个全局活动根在两个项目间快速抖动。
3. 新根持久化失败
内存已经把 B 设为活动项目,但注册表或实例记录写入失败。当前代码恢复原活动项目标识,并尝试把旧根重新写回实例记录。这个补偿缩小了分裂窗口,却仍需要错误日志和健康检查确认恢复结果。
4. 组件根不一致
Runtime 已是 B,MCP 仍是 A。此时最危险的做法是“多数表决”或选择最新时间戳。系统应报告不一致并停止写入,让有权角色决定重启或恢复。
这与 TMPA 的冲突保留原则一致:遇到矛盾事实,不通过概率推断假装一致。
5. Windows 文件句柄尚未释放
会话返回“已取消”,不代表旧子进程、Git、编译器或文件监听器已经释放目录句柄。句柄可以理解为程序仍握在文件或目录上的把手:工人虽然说停工,扳手却还卡在螺丝上。在 Windows 上,后续移动、清理或重建可能因此得到 EBUSY(资源仍被占用)、EPERM(当前操作无权执行)或访问被拒绝。当前 27 项测试没有注入这类占用故障。
可靠做法是把“收到停止确认”和“资源确实释放”分成两步:对受控子进程维护所有权清单;停止后用有界退避检查活动进程、监听器和关键文件探针;超时则保持旧根权威并报告占用者。不要为了让切换继续而盲目杀死系统中同名进程,也不要在句柄状态未知时删除运行目录。
6. 两个路径其实指向同一目录
大小写、目录链接、挂载别名或 fcop/ 子目录可能让字符串不同的路径指向同一工作区,也可能让看似同一的路径经解析后落到不同位置。当前实现已经覆盖部分 Windows 路径和 fcop/ 归一化,但没有证明所有符号链接别名都得到统一处理。应将真实路径解析加入项目注册、绑定计划和健康检查,并把解析失败作为阻断条件。
为什么切换时不能顺手“修好”目标项目
新项目可能缺少 FCoP 初始化文件、技能投影或其他运行资产。直觉上,系统可以在切换时自动复制一份,省去用户确认。
但项目切换和项目初始化是两种权力不同的操作。前者只改变当前工作对象;后者会写入目标项目。CodeFlowMu 的回归测试明确要求,项目切换路径不能执行未经批准的 Open Edition 投影修复,而应通过独立初始化计划和管理员确认。
否则,“查看项目 B”会悄悄变成“修改项目 B”。对于刚从外部仓库拉取的代码,这个副作用尤其危险。
27 个测试真正证明了什么
本次写作在独立工作树固定 commit ed5634c718b9e238c44bb70851020c9793546fe6,运行了 Runtime 项目上下文和 Shell 项目切换测试,共 27/27 通过。
覆盖内容包括:
- 不可变项目上下文同时绑定任务、提交、生命周期和证据根;
- 输入
fcop/目录时正确归一化业务根,避免形成fcop/fcop; - 初始化期间写入者等待,或同进程同步写入快速失败而不是死锁;
- 活动项目注册、切换和重载绑定计划;
- 安装源码根不能成为开发项目;
- 已消失或损坏的项目记录安全回退并产生诊断;
- Runtime、MCP、Watcher 和 Cursor 工作目录共享同根计划;
- 两个界面入口使用同一切换处理函数;
- 切换不会执行未经批准的项目投影修复。
这些测试验证固定版本的受测逻辑,不证明任意第三方 MCP、IDE 扩展或操作系统进程都会放弃自己缓存的旧目录,也没有覆盖高频并发切换、优雅排空超时、Windows EBUSY 句柄占用或符号链接别名的竞态空间。
可以直接加入产品的切换验收
切换前:
- 记录旧根和目标根的规范化绝对路径;
- 阻止目标指向安装源码根、父目录或不存在目录;
- 列出活动会话和持有写入租约的组件;
- 明确这次只是切换,还是还包含初始化写入。
- 将逻辑路径解析为真实物理路径;解析失败或与受保护根重合时停止。
切换中:
- 先关闭新任务入口,再给旧会话一个有界排空窗口;
- 重新核对会话、子进程、监听器、写入租约和 Windows 句柄;
- 只有全部停止成功才保存新根;
- 持久化失败时恢复旧标识并记录补偿结果;
- 用一份绑定计划重建 Runtime、MCP、Watcher 和 Agent 工作目录。
切换后:
- 健康接口返回逻辑路径和真实
projectRoot,而不是只返回项目名称; - 创建一条只读探针,分别读取任务根、生命周期根和证据根;
- 对每个组件计算根摘要,发现不一致就阻止写入;
- 用一次临时任务验证 TASK、代码变更和 REPORT 全部落在新根后,再恢复正式工作。
真实路径、排空窗口、句柄探针、根摘要和临时任务验收是本文提出的工程建议,不是当前切换接口已经完整实现的流程,也不是 FCoP 规范字段。团队可以选择其他实现,只要能证明所有工作事实属于同一个项目。
适用边界
如果一个程序只打开一个目录、没有后台进程、没有外部工具,也不允许运行中切换项目,那么关闭程序再从新目录启动可能比热切换更安全。
如果需要多个项目真正并行,单一“活动项目”模型也不够。应为每个项目建立独立 Runtime 身份、进程、监听器和证据根,再由上层控制面选择观察哪个实例。
网络文件系统、容器挂载和远程开发还会增加路径映射问题。本文测试来自 Windows 本地工作树,不支持“所有文件系统都已验证”的结论。
结论
项目切换失败的根因通常不是 Agent 忘了项目名,而是系统把“界面选择”错当成“执行上下文已经完成重绑定”。
当前项目不是一个标签,而是一组必须同时成立的根绑定事实。
安全切换需要停止旧工作、持久化新根、重建所有项目作用域组件、核对同根不变量,再恢复任务。少一步,Agent 都可能在错误项目里做出完全正确的操作。
主要来源
- Node.js Child Process:
cwd - VS Code Multi-root Workspaces
- VS Code Workspace Trust
- CodeFlowMu ProjectExecutionContext 固定提交
- CodeFlowMu project-registry 固定提交
- CodeFlowMu web-panel 项目切换固定提交
- TMPA 核心规范 S1.0,固定提交
访问日期:2026-08-20。