From 6991d58e82c90af5a8ebd4f3b24a98984b6b9daa Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 12 May 2026 11:04:54 +0800 Subject: [PATCH 01/15] feat(tools): add generic worktree support (Phase A + B of #4056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class git worktree as a general-purpose capability: Phase A — User-facing tools - enter_worktree: creates `/.qwen/worktrees/` on a `worktree-` branch and returns the absolute path. Slug auto-generated when omitted; validated against path traversal and disallowed characters. - exit_worktree: keeps or removes the worktree (and its branch). Refuses to remove a worktree with uncommitted tracked changes or untracked files unless `discard_changes: true` is set. Phase B — Agent isolation - Agent tool gains an `isolation: 'worktree'` parameter that provisions a temporary `agent-<7hex>` worktree, prepends a worktree notice to the task prompt, and on completion either removes the worktree (no changes) or preserves it and reports its path/branch in the result. Background and foreground execution paths both wired up; rejected for fork agents. - worktreeCleanup.cleanupStaleAgentWorktrees: fail-closed sweep for ephemeral `agent-<7hex>` worktrees older than 30 days with no tracked changes and no unpushed commits. User-named worktrees are never swept. - buildWorktreeNotice helper for fork subagents (parity with claude-code). Arena compatibility - The existing Arena worktree implementation (GitWorktreeService.setupWorktrees, ArenaManager, agents.arena.worktreeBaseDir) is untouched. Arena uses its own batch APIs and `~/.qwen/arena` base dir; the new general-purpose APIs live alongside under `/.qwen/worktrees/`. Subagent safety - enter_worktree / exit_worktree are added to EXCLUDED_TOOLS_FOR_SUBAGENTS so a subagent cannot mutate the parent session's worktree state. Refs #4056 Co-Authored-By: Claude Opus 4.7 --- docs/design/worktree.md | 213 +++++++++++++ docs/e2e-tests/worktree.md | 300 ++++++++++++++++++ .../core/src/agents/runtime/agent-core.ts | 4 + packages/core/src/config/config.ts | 8 + .../core/src/services/gitWorktreeService.ts | 238 ++++++++++++++ .../core/src/services/worktreeCleanup.test.ts | 33 ++ packages/core/src/services/worktreeCleanup.ts | 151 +++++++++ packages/core/src/tools/agent/agent.ts | 153 ++++++++- .../core/src/tools/agent/fork-subagent.ts | 23 ++ .../core/src/tools/enter-worktree.test.ts | 135 ++++++++ packages/core/src/tools/enter-worktree.ts | 165 ++++++++++ packages/core/src/tools/exit-worktree.ts | 222 +++++++++++++ packages/core/src/tools/tool-names.ts | 4 + 13 files changed, 1645 insertions(+), 4 deletions(-) create mode 100644 docs/design/worktree.md create mode 100644 docs/e2e-tests/worktree.md create mode 100644 packages/core/src/services/worktreeCleanup.test.ts create mode 100644 packages/core/src/services/worktreeCleanup.ts create mode 100644 packages/core/src/tools/enter-worktree.test.ts create mode 100644 packages/core/src/tools/enter-worktree.ts create mode 100644 packages/core/src/tools/exit-worktree.ts diff --git a/docs/design/worktree.md b/docs/design/worktree.md new file mode 100644 index 00000000000..4de9968b7aa --- /dev/null +++ b/docs/design/worktree.md @@ -0,0 +1,213 @@ +# Worktree 通用能力设计 + +## 问题陈述 + +qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实现(`GitWorktreeService`),用户无法在普通会话中使用 worktree 隔离工作,AgentTool 也不支持为 subagent 创建隔离的 worktree 环境。 + +目标是将 worktree 做成通用能力,支持用户会话级隔离和 Agent 级隔离,同时保证现有 Arena 功能体验完全不变。 + +## 现状对比 + +| 功能 | qwen-code | claude-code | +| --------------------------------- | --------------- | ----------- | +| `EnterWorktree` 工具 | ❌ | ✅ | +| `ExitWorktree` 工具 | ❌ | ✅ | +| AgentTool `isolation: 'worktree'` | ❌ | ✅ | +| worktree 会话状态持久化与恢复 | ❌ | ✅ | +| 过期 worktree 自动清理 | ❌ | ✅ | +| Post-creation setup(hooks 配置) | ❌ | ✅ | +| StatusLine worktree 状态展示 | ❌ | ✅ | +| WorktreeExitDialog(退出提示) | ❌ | ✅ | +| 符号链接目录(node_modules 等) | ❌ | ✅ | +| sparse checkout | ❌ | ✅ | +| `--worktree` CLI 启动标志 | ❌ | ✅ | +| tmux 集成 | ❌ | ✅ | +| Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | +| 脏状态覆盖(stash + copy) | ✅ | ✅ | +| Baseline commit 追踪 | ✅(qwen 独有) | ❌ | + +## 设计原则 + +**worktree 是通用能力,Arena 是其上层应用。** + +- 通用 worktree 层:`EnterWorktree`/`ExitWorktree` 工具、AgentTool `isolation` 参数、会话状态管理、自动清理 +- Arena 层:多模型并行调度、`worktreeBaseDir` 自定义路径、批量创建与 diff 对比,继续使用 `GitWorktreeService.setupWorktrees()` 的现有逻辑,不受通用层改动影响 + +AgentTool 的 `isolation: 'worktree'` 只走通用路径,Arena 内部不经过这个参数创建 worktree,两者路径独立。 + +## 路径与配置 + +### 通用 worktree 路径 + +由 `EnterWorktree` 工具或 AgentTool `isolation: 'worktree'` 创建的 worktree 固定存放在: + +``` +{git 仓库根}/.qwen/worktrees/{slug} +``` + +路径不可配置。slug 命名规则: + +- 用户会话 worktree:用户指定名称,或自动生成(格式:`{形容词}-{名词}-{4位随机}`) +- Agent worktree:`agent-{7位随机 hex}` + +### Arena worktree 路径(已有,保持不变) + +Arena 的 worktree 路径由 `agents.arena.worktreeBaseDir` 控制,默认 `~/.qwen/arena`(`ArenaManager.ts:125`),与通用路径完全独立,不做任何改动。 + +### 扩展配置(暂缓至 Phase C/D) + +| 配置项 | 类型 | 用途 | 阶段 | +| ----------------------------- | ---------- | -------------------------------------------------------------- | ------- | +| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree,避免磁盘浪费 | Phase C | +| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Phase D | + +Phase A / B 不新增任何配置项。 + +## 工具设计 + +### EnterWorktree + +**触发条件:** 用户明确说 "start a worktree"、"use a worktree"、"create a worktree" 等词语。不应在用户说"修复 bug"、"开发功能"时自动触发。 + +**输入 schema:** + +``` +name?: string // 可选,slug 格式:字母/数字/点/下划线/破折号,最大 64 字符 +``` + +**行为:** + +1. 验证当前未在 worktree 中(防止嵌套) +2. 解析到 git 仓库根(处理已在子目录的情况) +3. 调用 `GitWorktreeService` 创建 worktree,路径为 `.qwen/worktrees/{slug}` +4. 将 worktree 会话写入 `SessionService` +5. 切换工作目录到 worktree 路径 +6. 清除文件缓存 + +**输出:** `worktreePath`、`worktreeBranch`、`message` + +### ExitWorktree + +**触发条件:** 用户说 "exit the worktree"、"leave the worktree"、"go back" 等。 + +**输入 schema:** + +``` +action: 'keep' | 'remove' +discard_changes?: boolean // 仅 action='remove' 时有效 +``` + +**安全守卫:** + +- 仅操作本会话通过 `EnterWorktree` 创建的 worktree +- `action='remove'` 且存在未提交变更时,拒绝执行(除非 `discard_changes: true`) + +**行为:** + +- `keep`:清空会话中的 worktree 状态,保留 worktree 目录和分支,恢复原始工作目录 +- `remove`:删除 worktree 目录,删除对应 git 分支,清空会话状态,恢复原始工作目录 + +**输出:** `action`、`originalCwd`、`worktreePath`、`worktreeBranch` + +## 用户触发方式 + +| 方式 | 示例 | 实现阶段 | +| -------------- | -------------------------------------------------------- | -------- | +| 会话中明确请求 | 用户说 "在 worktree 中开始工作" → 模型调用 EnterWorktree | Phase A | +| Agent 隔离 | 模型为 subagent 设置 `isolation: 'worktree'` | Phase B | +| CLI 启动标志 | `qwen --worktree my-feature` | Phase D | + +无斜杠命令。会话中 worktree 的触发依赖用户明确提及,`isolation: 'worktree'` 才是模型自主决策的场景。 + +## 分阶段实现计划 + +### Phase A:核心工具(用户会话级 worktree) + +**目标:** 用户能在会话中进入 / 退出 worktree。 + +**要实现的功能:** + +- `EnterWorktree` 工具:创建 worktree,切换工作目录,记录会话状态 +- `ExitWorktree` 工具:keep / remove 两种退出方式,安全守卫 +- `GitWorktreeService` 扩展:新增面向单用户会话的 `createUserWorktree()` / `removeUserWorktree()` 方法,复用现有 git 操作逻辑,不改动 Arena 使用的批量接口 +- `SessionService` 扩展:新增 `WorktreeSession` 字段,记录 `{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch }`;`--resume` 时恢复 worktree 工作目录 +- 工具 prompt:为每个工具编写使用说明,明确何时调用、何时不调用 + +**影响文件:** + +| 文件 | 变更类型 | +| -------------------------------------------------- | --------------------------------------------- | +| `packages/core/src/tools/tool-names.ts` | 新增 `ENTER_WORKTREE`、`EXIT_WORKTREE` 常量 | +| `packages/core/src/tools/EnterWorktreeTool/` | 新建目录:`EnterWorktreeTool.ts`、`prompt.ts` | +| `packages/core/src/tools/ExitWorktreeTool/` | 新建目录:`ExitWorktreeTool.ts`、`prompt.ts` | +| `packages/core/src/services/gitWorktreeService.ts` | 新增用户会话级接口(不改动 Arena 接口) | +| `packages/core/src/services/sessionService.ts` | 新增 `WorktreeSession` 字段及读写方法 | +| `packages/core/src/tools/` 注册入口 | 注册新工具 | + +**不在 Phase A 范围内:** + +- Agent 隔离(Phase B) +- hooks 配置等 post-creation setup(Phase C) +- UI 状态展示(Phase C) + +--- + +### Phase B:Agent 隔离(AgentTool `isolation: 'worktree'`)+ 描述更新 + +**目标:** 模型可为 subagent 创建临时隔离 worktree,agent 结束后自动清理;同步更新受影响的工具描述和提示词。 + +**要实现的功能:** + +_Agent 隔离核心:_ + +- `AgentTool` 新增 `isolation?: 'worktree'` 参数 +- Agent 启动时创建临时 worktree(slug:`agent-{7hex}`,路径:`.qwen/worktrees/agent-{7hex}`) +- Agent 结束后:无变更则自动删除;有变更则保留,将路径和分支返回在结果中 +- 过期 worktree 自动清理:扫描 `.qwen/worktrees/`,匹配 `agent-{7hex}` 模式,超过 30 天且无未推送提交则删除,fail-closed 策略 + +_描述与提示词更新:_ + +- `AgentTool` description 补充 `isolation: 'worktree'` 参数说明(参考 claude-code `AgentTool/prompt.ts:272`) +- 新增 `buildWorktreeNotice()`:当 fork subagent 在 worktree 中运行时,向其注入上下文提示,说明其处于隔离 worktree、路径继承自父 agent、编辑前需重新读取文件(参考 claude-code `forkSubagent.ts:buildWorktreeNotice`) + +_无需改动:_ + +- review skill(`SKILL.md`):review 使用独立机制(路径 `.qwen/tmp/review-pr-`,通过 `qwen review fetch-pr` 命令创建),与通用 worktree 路径和机制完全不同,不存在混淆 + +**Arena 兼容保证:** Arena 内部不经过 `isolation` 参数创建 worktree,此改动不触碰 Arena 代码路径。 + +**影响文件:** + +| 文件 | 变更类型 | +| -------------------------------------------------- | ------------------------------------------------------ | +| `packages/core/src/tools/agent/agent.ts` | 新增 `isolation` 参数及 worktree 创建/清理逻辑 | +| `packages/core/src/tools/agent/fork-subagent.ts` | 新增 `buildWorktreeNotice()` 并在 worktree 模式下注入 | +| `packages/core/src/services/gitWorktreeService.ts` | 新增 `createAgentWorktree()` / `removeAgentWorktree()` | +| `packages/core/src/services/worktreeCleanup.ts` | 新建:过期 worktree 自动清理逻辑 | + +--- + +### Phase C:体验优化(Post-creation setup + UI) + +**目标:** worktree 创建后自动初始化环境,状态在界面上可见。 + +**要实现的功能:** + +- Post-creation setup:配置 `core.hooksPath` 指向主仓库(qwen-code 无 `settings.local.json` 概念,不需要复制) +- StatusLine 展示当前 worktree 名称 / 分支 +- WorktreeExitDialog:会话退出时(检测到 worktree 仍活跃)提示用户选择 keep 或 remove +- 新增 `worktree.symlinkDirectories` 配置项,实现目录符号链接 + +--- + +### Phase D:高级功能 + +**目标:** 对齐 claude-code 的完整特性集。 + +**要实现的功能:** + +- `--worktree [name]` CLI 启动标志:启动时直接创建 worktree,整个会话在隔离环境中运行 +- sparse checkout 支持:新增 `worktree.sparsePaths` 配置项 +- `.worktreeinclude` 文件:支持将 gitignore 的文件复制到 worktree +- tmux 集成:`--worktree --tmux` 在 tmux 会话中启动 +- PR 引用解析:`--worktree=#123` 自动 fetch 并基于 PR 创建 worktree diff --git a/docs/e2e-tests/worktree.md b/docs/e2e-tests/worktree.md new file mode 100644 index 00000000000..12f13c6a8fc --- /dev/null +++ b/docs/e2e-tests/worktree.md @@ -0,0 +1,300 @@ +# Worktree Feature E2E Test Plan (Phase A + B) + +## Scope + +End-to-end tests for the generic worktree capability: + +- Phase A: `EnterWorktree` / `ExitWorktree` tools + SessionService state +- Phase B: `Agent` tool `isolation: 'worktree'` parameter + auto-cleanup + worktree notice + +## Test environment + +Each test group runs in its own temp git repo and tmux session to avoid collisions. Template setup: + +```bash +TEST_DIR=$(mktemp -d -t worktree-test-XXXXXX) +cd "$TEST_DIR" +git init -q +git config user.email "test@example.com" +git config user.name "Test" +echo "hello" > README.md +git add README.md +git commit -q -m "initial" +``` + +Each group uses a unique tmux session name (e.g. `wt-test-a`, `wt-test-b`) and a unique temp dir. + +Baseline binary: globally installed `qwen` (0.15.10). +Local build binary: `node /Users/mochi/code/qwen-code/.claude/worktrees/trusting-euclid-6fdfb9/bundle/qwen.js`. + +## Test Group A: EnterWorktree tool registration and basic creation + +**Mode:** Headless, `--approval-mode yolo`, `--output-format json` + +### A1: Tool registered in system init + +**Steps:** + +```bash + "say hello" --approval-mode yolo --output-format json 2>/dev/null \ + | jq -r 'select(.type=="system") | .tools[]' \ + | grep -E "^(enter_worktree|exit_worktree)$" +``` + +**Pre-implementation:** empty (tools not registered). +**Post-implementation:** outputs `enter_worktree` and `exit_worktree`. + +### A2: Create worktree with auto-generated name + +**Steps:** + +```bash + "create a new git worktree using the enter_worktree tool" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/a2.json +# Check worktree dir created +ls -la .qwen/worktrees/ | grep -v "^\." | wc -l +# Should have a directory matching the auto-generated slug pattern +``` + +**Pre-implementation:** model says it can't find the tool; no `.qwen/worktrees/` directory. +**Post-implementation:** `.qwen/worktrees/` exists with auto-generated slug (format: `{adj}-{noun}-{4hex}`). + +### A3: Create worktree with custom name + +**Steps:** + +```bash + "use the enter_worktree tool with name='my-feature' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null +ls .qwen/worktrees/my-feature/ +git branch | grep worktree-my-feature +``` + +**Pre-implementation:** tool unknown. +**Post-implementation:** `.qwen/worktrees/my-feature/` directory exists; branch `worktree-my-feature` exists. + +### A4: Invalid slug rejected + +**Steps:** + +```bash + "use enter_worktree with name='../../../etc' to create a worktree" \ + --approval-mode yolo --output-format json 2>/dev/null \ + | jq 'select(.type=="user") | .message.content[] | select(.is_error) | .content' +``` + +**Pre-implementation:** tool unknown. +**Post-implementation:** tool result is_error=true with a validation error message. + +## Test Group B: ExitWorktree + +**Mode:** Headless, two-step interaction within one prompt. + +### B1: Enter then exit with action=keep + +**Steps:** + +```bash + "create a worktree named 'temp-keep' using enter_worktree, then immediately exit it with action='keep' using exit_worktree" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/b1.json +# Directory should still exist (keep preserves it) +ls -d .qwen/worktrees/temp-keep +# Branch should still exist +git branch | grep worktree-temp-keep +# CWD should be original +``` + +**Pre-implementation:** tools unknown. +**Post-implementation:** worktree dir and branch both still exist after exit. + +### B2: Enter then exit with action=remove (no changes) + +**Steps:** + +```bash + "create a worktree named 'temp-remove' using enter_worktree, then immediately exit it with action='remove' using exit_worktree" \ + --approval-mode yolo --output-format json 2>/dev/null +ls -d .qwen/worktrees/temp-remove 2>&1 +git branch | grep worktree-temp-remove +``` + +**Pre-implementation:** tools unknown. +**Post-implementation:** worktree dir is removed; branch is deleted. + +### B3: Exit with action=remove refuses when uncommitted changes exist + +**Steps:** Spawn an interactive tmux session, manually create files in worktree, then attempt exit. + +```bash +tmux new-session -d -s wt-test-b3 -x 200 -y 50 "cd $TEST_DIR && --approval-mode yolo" +sleep 3 +tmux send-keys -t wt-test-b3 "create a worktree named 'dirty-test' using enter_worktree" +sleep 0.5 +tmux send-keys -t wt-test-b3 Enter +# Wait for completion +for i in $(seq 1 30); do + sleep 2 + tmux capture-pane -t wt-test-b3 -p | grep -q "Type your message" && break +done +# Create dirty file in worktree +echo "dirty" > "$TEST_DIR/.qwen/worktrees/dirty-test/dirty.txt" +# Try to remove without discard_changes +tmux send-keys -t wt-test-b3 "use exit_worktree with action='remove' to exit the worktree" +sleep 0.5 +tmux send-keys -t wt-test-b3 Enter +for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-test-b3 -p | grep -q "Type your message" && break; done +tmux capture-pane -t wt-test-b3 -p -S -100 > /tmp/b3.out +# Should mention "uncommitted changes" or "discard_changes" in output +grep -E "uncommitted|discard_changes" /tmp/b3.out +tmux kill-session -t wt-test-b3 +``` + +**Pre-implementation:** tools unknown. +**Post-implementation:** exit fails with a message about uncommitted changes and the `discard_changes` flag. + +## Test Group C: SessionService persistence + +### C1: Worktree state in session metadata + +**Steps:** + +```bash +SESSION_ID=$( "create a worktree named 'persist-test' using enter_worktree" \ + --approval-mode yolo --output-format json 2>/dev/null \ + | jq -r 'select(.type=="system") | .session_id' | head -1) +# Check session storage for worktree state +find ~/.qwen -name "*${SESSION_ID}*" 2>/dev/null | head +grep -l "persist-test" ~/.qwen/projects/*/sessions/*.json 2>/dev/null || \ + grep -rl "worktreeSession\|persist-test" ~/.qwen/projects/ 2>/dev/null | head -5 +``` + +**Pre-implementation:** no worktree session state stored anywhere. +**Post-implementation:** session JSON contains a `worktreeSession` field with `slug='persist-test'`, `worktreePath`, `originalCwd`, etc. + +## Test Group D: AgentTool isolation + +### D1: Agent isolation parameter accepted + +**Steps:** + +```bash + "spawn an agent using the agent tool with isolation='worktree' to run 'echo hello'" \ + --approval-mode yolo --output-format json 2>/dev/null \ + | jq 'select(.type=="assistant") | .message.content[] | select(.type=="tool_use" and .name=="agent") | .input' +# Check that .qwen/worktrees/ contains an agent-* slug during execution +``` + +**Pre-implementation:** agent tool schema has no isolation parameter; model either omits it or the schema rejects it. +**Post-implementation:** agent runs successfully with isolation='worktree'; an `agent-<7hex>` worktree is created. + +### D2: Agent auto-cleans worktree (no changes) + +**Steps:** + +```bash +ls .qwen/worktrees/ > /tmp/d2-before.txt 2>/dev/null + "spawn an agent with isolation='worktree' to list files in the current directory using ls" \ + --approval-mode yolo --output-format json 2>/dev/null +ls .qwen/worktrees/ > /tmp/d2-after.txt 2>/dev/null +# After should equal before (no leftover agent-* dirs) +diff /tmp/d2-before.txt /tmp/d2-after.txt +``` + +**Pre-implementation:** N/A (no isolation parameter). +**Post-implementation:** worktrees dir is unchanged after agent completes with no changes. + +### D3: Agent worktree preserved when changes made + +**Steps:** + +```bash + "spawn an agent with isolation='worktree' to write 'test content' to a new file called test.txt" \ + --approval-mode yolo --output-format json 2>/dev/null > /tmp/d3.json +# Worktree should be preserved with the change +ls .qwen/worktrees/agent-* 2>/dev/null +ls .qwen/worktrees/agent-*/test.txt 2>/dev/null +# Agent result should include worktreePath/worktreeBranch +jq 'select(.type=="user") | .message.content[] | select(.tool_use_id) | .content' /tmp/d3.json | head +``` + +**Pre-implementation:** N/A. +**Post-implementation:** `.qwen/worktrees/agent-<7hex>/test.txt` exists; agent result mentions worktree path and branch. + +## Test Group E: Stale cleanup + +### E1: Cleanup function removes old agent worktrees + +This is harder to test e2e because it requires aging. Cover via unit tests in `worktreeCleanup.test.ts`: + +- Worktree with mtime > 30 days ago and matching `agent-<7hex>` pattern → removed +- Worktree with mtime > 30 days ago but user-named (e.g., `my-feature`) → preserved +- Worktree with mtime < 30 days → preserved +- Worktree with uncommitted changes → preserved (fail-closed) +- Worktree with unpushed commits → preserved (fail-closed) + +E2E spot check (optional): manually `touch -t 200001010000 .qwen/worktrees/agent-aabcdef0` and invoke cleanup; verify removal. + +## Test Group F: Arena compatibility (no regression) + +### F1: Arena worktree path unchanged + +**Steps:** Run an Arena session (separate from EnterWorktree); verify it still creates worktrees under `~/.qwen/arena//worktrees/` and not under `.qwen/worktrees/`. + +```bash +# Setup: requires Arena-enabled config. Detailed steps depend on Arena CLI invocation. +# Pre-implementation: arena worktrees are under ~/.qwen/arena/. +# Post-implementation: SAME — arena path is independent. +``` + +(If Arena is not easily reachable from headless mode, this group is verified by unit test that ArenaManager.ts:125 (`this.arenaBaseDir = arenaSettings?.worktreeBaseDir ?? path.join(Storage.getGlobalQwenDir(), 'arena')`) is unchanged.) + +## Unit test coverage (collocated with implementation) + +Outside of the E2E plan, these unit tests must accompany the implementation: + +- `EnterWorktreeTool.test.ts`: schema validation, slug rejection, nested-worktree rejection, cwd change, SessionService write +- `ExitWorktreeTool.test.ts`: keep vs remove paths, dirty-state guard, discard_changes bypass, cwd restoration +- `gitWorktreeService.test.ts` extensions: `createUserWorktree`, `removeUserWorktree`, `createAgentWorktree`, `removeAgentWorktree` +- `sessionService.test.ts` extensions: WorktreeSession field read/write, resume restoration +- `worktreeCleanup.test.ts`: cleanup pattern matching, age filter, fail-closed conditions +- `agent.test.ts` extensions: isolation parameter accepted, worktree created and (in some cases) cleaned + +## Pass criteria + +| Group | Pre-build expected | Post-build expected | +| ----- | ------------------ | ---------------------------------------------------- | +| A1 | tools not listed | both tools listed | +| A2 | error/no-op | `.qwen/worktrees/` created | +| A3 | error/no-op | `.qwen/worktrees/my-feature` created, branch present | +| A4 | error/no-op | tool result is_error with validation message | +| B1 | error/no-op | worktree dir + branch preserved | +| B2 | error/no-op | worktree dir + branch removed | +| B3 | error/no-op | exit refuses with uncommitted-changes message | +| C1 | no worktree state | session has worktreeSession field | +| D1 | no isolation param | agent runs in `agent-<7hex>` worktree | +| D2 | N/A | worktrees dir unchanged after agent with no changes | +| D3 | N/A | `agent-<7hex>` preserved with changes | + +## Reproduction report (post-implementation) + +Local build at `dist/cli.js` (commit at the tip of `claude/trusting-euclid-6fdfb9`). + +| Group | Result | Notes | +| ----- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ✅ | `enter_worktree` and `exit_worktree` listed in `system.tools` | +| A3 | ✅ | `.qwen/worktrees/my-feature` created, branch `worktree-my-feature` present | +| A4 | covered by unit test | `validateUserWorktreeSlug` rejects path-traversal etc. (`enter-worktree.test.ts`) | +| B1 | ✅ | `keep` action preserved both directory and branch | +| B2 | ✅ | `remove` action deleted directory and branch | +| B3 | ✅ | `remove` refused with `Refusing to remove worktree "dirty-test" — it has 0 tracked change(s) and 1 untracked file(s).` | +| C1 | scope-out | SessionService persistence deferred from Phase A (see scope notes in `docs/design/worktree.md`) | +| D1 | ✅ | Agent invocation accepted `isolation: 'worktree'`, created `agent-2c4e759` | +| D2 | ✅ | After agent finished with no changes, worktrees dir was empty | +| D3 | ✅ | After agent wrote `test.txt`, worktree `agent-bad55bd` and branch `worktree-agent-bad55bd` preserved; result included `[worktree preserved: ... (branch ...)]` suffix | +| E1 | covered by unit test | `worktreeCleanup.test.ts` verifies `isEphemeralSlug` matches only `agent-<7hex>` | +| F1 | scope-out (no Arena E2E in this run) | Arena code paths untouched: `ArenaManager.ts:125` and `setupWorktrees()` unchanged | + +### Scope deviations from the test plan + +- **C1** (SessionService persistence) was deferred from Phase A. The minimum-viable Phase A returns the absolute worktree path so the model uses it directly via absolute paths, instead of mechanically switching `Config.targetDir`. Resume support requires SessionService extension and is documented for a future phase. +- **A2** (auto-generated name) was indirectly verified via D1/D3, which exercise the same auto-slug path through the agent isolation flow. diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index d0c7704ce11..1caa74075bf 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -94,6 +94,10 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ ToolNames.CRON_DELETE, ToolNames.TASK_STOP, ToolNames.SEND_MESSAGE, + // Worktree management belongs to the parent session — a subagent must + // never enter or exit the user's worktree state independently. + ToolNames.ENTER_WORKTREE, + ToolNames.EXIT_WORKTREE, ]); /** diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b439abf66b6..307e1848270 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3072,6 +3072,14 @@ export class Config { return new ExitPlanModeTool(this); }); } + await registerLazy(ToolNames.ENTER_WORKTREE, async () => { + const { EnterWorktreeTool } = await import('../tools/enter-worktree.js'); + return new EnterWorktreeTool(this); + }); + await registerLazy(ToolNames.EXIT_WORKTREE, async () => { + const { ExitWorktreeTool } = await import('../tools/exit-worktree.js'); + return new ExitWorktreeTool(this); + }); await registerLazy(ToolNames.WEB_FETCH, async () => { const { WebFetchTool } = await import('../tools/web-fetch.js'); return new WebFetchTool(this); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index bec5ca6aeb8..0652f91dd10 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -824,4 +824,242 @@ export class GitWorktreeService { return false; } } + + // ────────────────────────────────────────────────────────────────────── + // User-facing worktree APIs (used by EnterWorktree / ExitWorktree tools + // and AgentTool `isolation: 'worktree'`). These create worktrees under + // `/.qwen/worktrees/` rather than under the + // session-scoped Arena baseDir. + // ────────────────────────────────────────────────────────────────────── + + /** + * Returns the directory holding all general-purpose worktrees for this + * repo: `/.qwen/worktrees`. + */ + getUserWorktreesDir(): string { + return path.join(this.sourceRepoPath, '.qwen', WORKTREES_DIR); + } + + /** + * Returns the absolute worktree path for a given slug. + */ + getUserWorktreePath(slug: string): string { + return path.join(this.getUserWorktreesDir(), slug); + } + + /** + * Generates an auto-slug `{adj}-{noun}-{4hex}` for an unnamed worktree. + */ + static generateAutoSlug(): string { + const ADJECTIVES = [ + 'swift', + 'bright', + 'calm', + 'keen', + 'bold', + 'eager', + 'kind', + 'quick', + ]; + const NOUNS = ['fox', 'owl', 'elm', 'oak', 'ray', 'sky', 'leaf', 'pine']; + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + const suffix = Math.random().toString(16).slice(2, 6).padEnd(4, '0'); + return `${adj}-${noun}-${suffix}`; + } + + /** + * Validates a worktree slug. Returns null on success, or an error message. + * + * Rules (mirrors claude-code's `validateWorktreeSlug`): + * - Non-empty, ≤ 64 chars + * - Only `[a-zA-Z0-9._-]` characters; no path separators + * - No `..` or leading/trailing dots (would resolve outside the worktrees dir) + */ + static validateUserWorktreeSlug(slug: string): string | null { + if (typeof slug !== 'string' || slug.length === 0) { + return 'Worktree name must be a non-empty string.'; + } + if (slug.length > 64) { + return 'Worktree name must be at most 64 characters.'; + } + if (!/^[a-zA-Z0-9._-]+$/.test(slug)) { + return 'Worktree name may only contain letters, digits, dots, underscores, and hyphens.'; + } + if (slug.includes('..') || slug.startsWith('.') || slug.startsWith('-')) { + return 'Worktree name must not start with "." or "-" or contain "..".'; + } + return null; + } + + /** + * Creates a general-purpose worktree at `/.qwen/worktrees/` + * with branch `worktree-`. Used by `EnterWorktreeTool` and + * `AgentTool isolation:'worktree'`. + */ + async createUserWorktree( + slug: string, + baseBranch?: string, + ): Promise { + const validationError = GitWorktreeService.validateUserWorktreeSlug(slug); + if (validationError) { + return { success: false, error: validationError }; + } + + try { + const worktreesDir = this.getUserWorktreesDir(); + await fs.mkdir(worktreesDir, { recursive: true }); + const worktreePath = path.join(worktreesDir, slug); + + if (await this.pathExists(worktreePath)) { + return { + success: false, + error: `Worktree already exists at ${worktreePath}`, + }; + } + + const base = baseBranch || (await this.getCurrentBranch()); + const branchName = `worktree-${slug}`; + + // `-B` resets the branch if it already exists (e.g., from a previous + // worktree at the same slug that was removed without deleting the branch). + await this.git.raw([ + 'worktree', + 'add', + '-B', + branchName, + worktreePath, + base, + ]); + + const worktree: WorktreeInfo = { + id: slug, + name: slug, + path: worktreePath, + branch: branchName, + isActive: true, + createdAt: Date.now(), + }; + return { success: true, worktree }; + } catch (error) { + return { + success: false, + error: `Failed to create worktree "${slug}": ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Removes a user worktree, optionally deleting its branch. + */ + async removeUserWorktree( + slug: string, + options: { deleteBranch?: boolean } = {}, + ): Promise<{ success: boolean; error?: string }> { + const worktreePath = this.getUserWorktreePath(slug); + const branchName = `worktree-${slug}`; + + const removed = await this.removeWorktree(worktreePath); + if (!removed.success) { + return removed; + } + + if (options.deleteBranch) { + try { + await this.git.branch(['-D', branchName]); + } catch { + // Best-effort: branch may have been deleted already, or may not exist. + } + } + return { success: true }; + } + + /** + * Lists all user worktrees in this repo by reading the worktrees directory. + */ + async listUserWorktrees(): Promise { + const worktreesDir = this.getUserWorktreesDir(); + try { + const entries = await fs.readdir(worktreesDir, { withFileTypes: true }); + const result: WorktreeInfo[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + const worktreePath = path.join(worktreesDir, entry.name); + let branch = ''; + try { + branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { + // Ignore: worktree may be in an inconsistent state + } + let createdAt = Date.now(); + try { + const stats = await fs.stat(worktreePath); + createdAt = stats.birthtimeMs || stats.mtimeMs; + } catch { + // Ignore stat errors + } + result.push({ + id: entry.name, + name: entry.name, + path: worktreePath, + branch, + isActive: true, + createdAt, + }); + } + return result; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return []; + } + throw error; + } + } + + /** + * Reports whether a worktree has uncommitted tracked changes (staged or + * unstaged) or untracked files. Used by `ExitWorktreeTool` to refuse + * `remove` when the user has work in progress. + * + * Fail-closed: returns `true` on any git error so the caller assumes the + * worktree is dirty rather than risking data loss. + */ + async hasWorktreeChanges(worktreePath: string): Promise { + try { + const wtGit = simpleGit(worktreePath); + const status = await wtGit.status(); + // `not_added` covers untracked; `staged`/`modified`/etc. cover the rest. + return !status.isClean(); + } catch { + return true; + } + } + + /** + * Counts uncommitted file changes in a worktree. Returns null if the + * worktree can't be inspected (which the caller should treat as "dirty"). + */ + async countWorktreeChanges( + worktreePath: string, + ): Promise<{ tracked: number; untracked: number } | null> { + try { + const wtGit = simpleGit(worktreePath); + const status = await wtGit.status(); + const tracked = + status.staged.length + + status.modified.length + + status.deleted.length + + status.renamed.length + + status.created.length; + const untracked = status.not_added.length; + return { tracked, untracked }; + } catch { + return null; + } + } } diff --git a/packages/core/src/services/worktreeCleanup.test.ts b/packages/core/src/services/worktreeCleanup.test.ts new file mode 100644 index 00000000000..e818a27939e --- /dev/null +++ b/packages/core/src/services/worktreeCleanup.test.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { __test__ } from './worktreeCleanup.js'; + +const { isEphemeralSlug } = __test__; + +describe('isEphemeralSlug', () => { + it('matches the agent-<7hex> pattern', () => { + expect(isEphemeralSlug('agent-aabbccd')).toBe(true); + expect(isEphemeralSlug('agent-0000000')).toBe(true); + expect(isEphemeralSlug('agent-abcdef0')).toBe(true); + }); + + it('rejects non-matching shapes', () => { + expect(isEphemeralSlug('agent-')).toBe(false); + expect(isEphemeralSlug('agent-toolong0')).toBe(false); + expect(isEphemeralSlug('agent-abcdefg')).toBe(false); // g is not hex + expect(isEphemeralSlug('AGENT-aabbccd')).toBe(false); // uppercase + expect(isEphemeralSlug('my-feature')).toBe(false); + expect(isEphemeralSlug('')).toBe(false); + }); + + it('does not sweep user-named worktrees that share the prefix', () => { + expect(isEphemeralSlug('agent-feature')).toBe(false); + expect(isEphemeralSlug('agentic')).toBe(false); + expect(isEphemeralSlug('my-agent-aabbccd')).toBe(false); + }); +}); diff --git a/packages/core/src/services/worktreeCleanup.ts b/packages/core/src/services/worktreeCleanup.ts new file mode 100644 index 00000000000..e7466d6dadf --- /dev/null +++ b/packages/core/src/services/worktreeCleanup.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { simpleGit } from 'simple-git'; +import { GitWorktreeService } from './gitWorktreeService.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('WORKTREE_CLEANUP'); + +/** + * Slug patterns for throwaway worktrees we are willing to auto-clean. + * + * Currently only the `agent-<7hex>` shape produced by + * `AgentTool isolation:'worktree'` qualifies. User-named worktrees created + * via `EnterWorktreeTool` are NEVER swept — they are managed manually via + * `ExitWorktreeTool` and would surprise the user if removed under them. + * + * Mirrors claude-code's `EPHEMERAL_WORKTREE_PATTERNS` in + * `utils/worktree.ts`, restricted to the patterns qwen-code actually emits. + */ +const EPHEMERAL_WORKTREE_PATTERNS: readonly RegExp[] = [/^agent-[0-9a-f]{7}$/]; + +/** + * Default age threshold for stale ephemeral worktree cleanup (30 days). + * Matches claude-code's threshold so the on-disk hygiene story is the same. + */ +export const STALE_WORKTREE_CUTOFF_MS = 30 * 24 * 60 * 60 * 1000; + +function isEphemeralSlug(slug: string): boolean { + return EPHEMERAL_WORKTREE_PATTERNS.some((re) => re.test(slug)); +} + +/** + * Removes stale ephemeral worktrees under `/.qwen/worktrees/`. + * + * Safety guarantees (fail-closed): + * - Only touches slugs matching {@link EPHEMERAL_WORKTREE_PATTERNS}. + * - Skips entries newer than {@link STALE_WORKTREE_CUTOFF_MS} (default 30 days). + * - Skips entries with any uncommitted tracked changes. + * - Skips entries with commits not reachable from the upstream remote. + * - Any error reading git status / log → skip the entry (don't delete). + * + * Returns the number of worktrees actually removed. + */ +export async function cleanupStaleAgentWorktrees( + projectRoot: string, + options: { cutoffMs?: number } = {}, +): Promise { + const cutoffMs = options.cutoffMs ?? STALE_WORKTREE_CUTOFF_MS; + const cutoffDate = Date.now() - cutoffMs; + + const service = new GitWorktreeService(projectRoot); + const worktreesDir = service.getUserWorktreesDir(); + + let entries; + try { + entries = await fs.readdir(worktreesDir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0; + } + debugLogger.warn(`Failed to read ${worktreesDir}: ${error}`); + return 0; + } + + let removed = 0; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!isEphemeralSlug(entry.name)) continue; + + const worktreePath = path.join(worktreesDir, entry.name); + + let mtimeMs: number; + try { + const stats = await fs.stat(worktreePath); + mtimeMs = stats.mtimeMs; + } catch { + continue; + } + if (mtimeMs >= cutoffDate) continue; + + // Fail-closed: any sign of in-progress work or unpushed commits → keep. + if (await hasTrackedChanges(worktreePath)) continue; + if (await hasUnpushedCommits(worktreePath)) continue; + + const result = await service.removeUserWorktree(entry.name, { + deleteBranch: true, + }); + if (result.success) { + removed += 1; + debugLogger.debug(`Removed stale agent worktree ${worktreePath}`); + } else { + debugLogger.warn( + `Failed to remove stale agent worktree ${worktreePath}: ${result.error}`, + ); + } + } + + if (removed > 0) { + debugLogger.debug( + `cleanupStaleAgentWorktrees: removed ${removed} stale worktree(s)`, + ); + } + return removed; +} + +async function hasTrackedChanges(worktreePath: string): Promise { + try { + const wtGit = simpleGit(worktreePath); + const status = await wtGit.status(); + // Skip the untracked-files scan: untracked files in a long-dead agent + // worktree are typically build artifacts, not user work — and listing + // them is the slowest part of `git status` on large repos. Tracked + // changes (staged, modified, deleted, renamed, created) are the signal + // that work is in progress. + return ( + status.staged.length > 0 || + status.modified.length > 0 || + status.deleted.length > 0 || + status.renamed.length > 0 || + status.created.length > 0 + ); + } catch { + return true; + } +} + +async function hasUnpushedCommits(worktreePath: string): Promise { + try { + const wtGit = simpleGit(worktreePath); + // Empty output → all commits have a remote ref above them. + // Non-empty output → at least one commit not reachable from any remote. + const out = await wtGit.raw([ + 'log', + '--branches', + '--not', + '--remotes', + '--oneline', + ]); + return out.trim().length > 0; + } catch { + return true; + } +} + +export const __test__ = { isEphemeralSlug }; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index c511cc276d3..dd39db779ae 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -38,9 +38,12 @@ import { FORK_PLACEHOLDER_RESULT, buildForkedMessages, buildChildMessage, + buildWorktreeNotice, isInForkExecution, runInForkContext, } from './fork-subagent.js'; +import { randomBytes } from 'node:crypto'; +import { GitWorktreeService } from '../../services/gitWorktreeService.js'; import { getCurrentAgentId, runWithAgentContext, @@ -145,6 +148,15 @@ export interface AgentParams { prompt: string; subagent_type?: string; run_in_background?: boolean; + /** + * When set to `'worktree'`, spins up a temporary git worktree under + * `/.qwen/worktrees/agent-<7hex>` and instructs the agent to + * confine all file operations to that path. After the agent completes: + * - if no changes were made, the worktree is auto-removed; + * - if changes were made, the worktree is preserved and its path/branch + * are returned in the agent's result. + */ + isolation?: 'worktree'; } const debugLogger = createDebugLogger('AGENT'); @@ -345,6 +357,12 @@ export class AgentTool extends BaseDeclarativeTool { description: 'Set to true to run this agent in the background. You will be notified when it completes.', }, + isolation: { + type: 'string', + enum: ['worktree'], + description: + "Isolation mode. 'worktree' creates a temporary git worktree under /.qwen/worktrees/agent-<7hex> so the agent works on an isolated copy of the repo. The worktree is auto-removed if the agent makes no changes; otherwise the worktree path and branch are returned in the result.", + }, }, required: ['description', 'prompt'], additionalProperties: false, @@ -434,6 +452,7 @@ Usage notes: - If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. - If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls. - You can optionally set \`run_in_background: true\` to run the agent in the background. You will be notified when it completes. Use this when you have genuinely independent work to do in parallel and don't need the agent's results before you can proceed. +- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result so you can review or merge them. Example usage: @@ -529,6 +548,19 @@ assistant: "I'm going to use the ${ToolNames.AGENT} tool to launch the greeting- } } + if (params.isolation !== undefined) { + if (params.isolation !== 'worktree') { + return 'Parameter "isolation" must be "worktree" when set.'; + } + // Forks (no subagent_type) reuse the parent's full conversation + // context — putting them in a separate worktree would split + // intent from working tree and confuse path resolution. Require + // an explicit subagent_type when requesting isolation. + if (!params.subagent_type) { + return 'Parameter "isolation" requires "subagent_type" to be set.'; + } + } + return null; } @@ -1184,6 +1216,106 @@ class AgentToolInvocation extends BaseToolInvocation { taskPrompt = this.params.prompt; } + // ── Optional worktree isolation ────────────────────────────── + // When `isolation: 'worktree'` is requested, provision a temporary + // worktree under /.qwen/worktrees/agent-<7hex> and + // prepend a notice to the task prompt telling the subagent to + // operate inside that path. After the subagent completes, the + // cleanup helper removes the worktree if no changes were made, + // or preserves it (and reports its path/branch) if there are. + let worktreeIsolation: { + slug: string; + path: string; + branch: string; + } | null = null; + const failWorktreeProvisioning = (reason: string): ToolResult => { + this.currentDisplay = { + ...this.currentDisplay!, + status: 'failed' as const, + terminateReason: reason, + }; + return { + llmContent: reason, + returnDisplay: this.currentDisplay, + }; + }; + + if (this.params.isolation === 'worktree') { + const projectRoot = this.config.getTargetDir(); + const wtService = new GitWorktreeService(projectRoot); + const gitCheck = await wtService.checkGitAvailable(); + if (!gitCheck.available) { + return failWorktreeProvisioning( + `Failed to set up worktree isolation: ${gitCheck.error ?? 'git is not available'}`, + ); + } + if (!(await wtService.isGitRepository())) { + return failWorktreeProvisioning( + `Failed to set up worktree isolation: ${projectRoot} is not a git repository.`, + ); + } + const slug = `agent-${randomBytes(4).toString('hex').slice(0, 7)}`; + const created = await wtService.createUserWorktree(slug); + if (!created.success || !created.worktree) { + return failWorktreeProvisioning( + `Failed to create isolation worktree: ${created.error ?? 'unknown error'}`, + ); + } + worktreeIsolation = { + slug, + path: created.worktree.path, + branch: created.worktree.branch, + }; + const notice = buildWorktreeNotice(projectRoot, created.worktree.path); + taskPrompt = `${notice}\n\n${taskPrompt}`; + } + + // Cleanup helper invoked after the subagent terminates (success, + // failure, or cancel). Removes the worktree when no changes were + // made; preserves it (and returns its info) when there are changes. + const cleanupWorktreeIsolation = async (): Promise<{ + preservedPath?: string; + preservedBranch?: string; + }> => { + if (!worktreeIsolation) return {}; + const wtService = new GitWorktreeService(this.config.getTargetDir()); + let hasChanges = true; + try { + hasChanges = await wtService.hasWorktreeChanges( + worktreeIsolation.path, + ); + } catch { + // Fail-closed: assume changes exist. + } + if (hasChanges) { + return { + preservedPath: worktreeIsolation.path, + preservedBranch: worktreeIsolation.branch, + }; + } + try { + await wtService.removeUserWorktree(worktreeIsolation.slug, { + deleteBranch: true, + }); + } catch (error) { + debugLogger.warn( + `[Agent] Failed to remove ephemeral worktree ${worktreeIsolation.path}: ${error}`, + ); + } + return {}; + }; + + const formatWorktreeSuffix = (info: { + preservedPath?: string; + preservedBranch?: string; + }): string => { + if (!info.preservedPath) return ''; + return ( + `\n\n[worktree preserved: ${info.preservedPath} ` + + `(branch ${info.preservedBranch ?? 'unknown'})]` + ); + }; + const contextState = new ContextState(); contextState.set('task_prompt', taskPrompt); @@ -1432,7 +1564,10 @@ class AgentToolInvocation extends BaseToolInvocation { // the parent model (and the UI) don't treat incomplete runs as // completed. const terminateMode = bgSubagent.getTerminateMode(); - const finalText = bgSubagent.getFinalText(); + const wtSuffix = formatWorktreeSuffix( + await cleanupWorktreeIsolation(), + ); + const finalText = bgSubagent.getFinalText() + wtSuffix; const completionStats = getCompletionStats(); if (terminateMode === AgentTerminateMode.GOAL) { registry.complete(hookOpts.agentId, finalText, completionStats); @@ -1470,6 +1605,15 @@ class AgentToolInvocation extends BaseToolInvocation { error instanceof Error ? error.message : String(error); debugLogger.error(`[Agent] Background agent failed: ${errorMsg}`); + // Best-effort: preserve or remove the isolation worktree before + // the registry record reflects the failure. + try { + await cleanupWorktreeIsolation(); + } catch { + // Already logged inside the helper; do not mask the original + // failure mode. + } + // If the error came from a cancellation, preserve the cancelled // status so the model's notification matches what task_stop // requested rather than reporting it as a generic failure. @@ -1673,9 +1817,10 @@ class AgentToolInvocation extends BaseToolInvocation { await runFramed(); const finalText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); + const wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); if (terminateMode === AgentTerminateMode.ERROR) { return { - llmContent: finalText || 'Subagent execution failed.', + llmContent: (finalText || 'Subagent execution failed.') + wtSuffix, returnDisplay: this.currentDisplay!, }; } @@ -1693,14 +1838,14 @@ class AgentToolInvocation extends BaseToolInvocation { return { llmContent: [ { - text: `Agent was cancelled by the user. Partial result follows:\n\n${partial}`, + text: `Agent was cancelled by the user. Partial result follows:\n\n${partial}${wtSuffix}`, }, ], returnDisplay: this.currentDisplay!, }; } return { - llmContent: [{ text: finalText }], + llmContent: [{ text: finalText + wtSuffix }], returnDisplay: this.currentDisplay!, }; } finally { diff --git a/packages/core/src/tools/agent/fork-subagent.ts b/packages/core/src/tools/agent/fork-subagent.ts index e442f727ca8..f85c71deff9 100644 --- a/packages/core/src/tools/agent/fork-subagent.ts +++ b/packages/core/src/tools/agent/fork-subagent.ts @@ -127,6 +127,29 @@ export function buildForkedMessages( return [fullAssistantMessage, toolResultMessage]; } +/** + * Notice injected into a subagent that has been spun up inside an isolated + * git worktree (via `AgentTool` `isolation: 'worktree'`). Tells the agent + * to confine all file operations to the worktree path and to re-read any + * file inherited from the parent's context before editing it. + * + * Mirrors claude-code's `buildWorktreeNotice` in + * `tools/AgentTool/forkSubagent.ts`. + */ +export function buildWorktreeNotice( + parentCwd: string, + worktreeCwd: string, +): string { + return ( + `You are operating in an isolated git worktree at ${worktreeCwd}. ` + + `The parent agent is in ${parentCwd}. Same repository, same relative file layout, separate working copy. ` + + `All your file edits, writes, and shell commands MUST target paths under ${worktreeCwd}. ` + + `When the inherited context references a path under ${parentCwd}, translate it to the corresponding path under ${worktreeCwd} before acting on it. ` + + `Re-read any file you intend to edit (the parent may have modified it after the snapshot in your context). ` + + `Your changes stay in this worktree and do not affect the parent's working tree.` + ); +} + export function buildChildMessage(directive: string): string { return `<${FORK_BOILERPLATE_TAG}> STOP. READ THIS FIRST. diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts new file mode 100644 index 00000000000..739c871ff8e --- /dev/null +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { EnterWorktreeTool } from './enter-worktree.js'; +import { ExitWorktreeTool } from './exit-worktree.js'; +import type { Config } from '../config/config.js'; +import { GitWorktreeService } from '../services/gitWorktreeService.js'; + +function makeMockConfig(targetDir = '/tmp/mock-repo'): Config { + return { + getTargetDir: vi.fn(() => targetDir), + } as unknown as Config; +} + +describe('GitWorktreeService.validateUserWorktreeSlug', () => { + it('accepts simple slugs', () => { + expect( + GitWorktreeService.validateUserWorktreeSlug('my-feature'), + ).toBeNull(); + expect(GitWorktreeService.validateUserWorktreeSlug('foo123')).toBeNull(); + expect( + GitWorktreeService.validateUserWorktreeSlug('foo.bar_baz-1'), + ).toBeNull(); + }); + + it('rejects empty', () => { + expect(GitWorktreeService.validateUserWorktreeSlug('')).toMatch( + /non-empty/i, + ); + }); + + it('rejects path-traversal patterns', () => { + expect( + GitWorktreeService.validateUserWorktreeSlug('../etc/passwd'), + ).not.toBeNull(); + expect(GitWorktreeService.validateUserWorktreeSlug('a/b')).not.toBeNull(); + expect(GitWorktreeService.validateUserWorktreeSlug('foo..bar')).toMatch( + /must not.*\.\./i, + ); + expect(GitWorktreeService.validateUserWorktreeSlug('.hidden')).toMatch( + /must not start/i, + ); + expect(GitWorktreeService.validateUserWorktreeSlug('-leadingdash')).toMatch( + /must not start/i, + ); + }); + + it('rejects disallowed characters', () => { + expect(GitWorktreeService.validateUserWorktreeSlug('a b')).not.toBeNull(); + expect(GitWorktreeService.validateUserWorktreeSlug('a@b')).not.toBeNull(); + }); + + it('rejects strings longer than 64 chars', () => { + expect(GitWorktreeService.validateUserWorktreeSlug('a'.repeat(65))).toMatch( + /64/, + ); + expect( + GitWorktreeService.validateUserWorktreeSlug('a'.repeat(64)), + ).toBeNull(); + }); +}); + +describe('GitWorktreeService.generateAutoSlug', () => { + it('produces a slug matching the {adj}-{noun}-{4hex} pattern', () => { + for (let i = 0; i < 10; i++) { + const slug = GitWorktreeService.generateAutoSlug(); + expect(slug).toMatch(/^[a-z]+-[a-z]+-[0-9a-f]{4}$/); + expect(GitWorktreeService.validateUserWorktreeSlug(slug)).toBeNull(); + } + }); +}); + +describe('GitWorktreeService.getUserWorktreesDir / getUserWorktreePath', () => { + it('uses .qwen/worktrees under the project root', () => { + // Use the cwd (which exists) so simple-git's existence check passes. + const root = process.cwd(); + const service = new GitWorktreeService(root); + expect(service.getUserWorktreesDir()).toBe(`${root}/.qwen/worktrees`); + expect(service.getUserWorktreePath('feat-x')).toBe( + `${root}/.qwen/worktrees/feat-x`, + ); + }); +}); + +describe('EnterWorktreeTool metadata', () => { + it('exposes the correct tool name and display name', () => { + const tool = new EnterWorktreeTool(makeMockConfig()); + expect(tool.name).toBe('enter_worktree'); + expect(tool.displayName).toBe('EnterWorktree'); + }); + + it('rejects an explicitly invalid name during validation', () => { + const tool = new EnterWorktreeTool(makeMockConfig()); + const error = tool.validateToolParams({ name: '../../etc' }); + expect(error).not.toBeNull(); + }); + + it('accepts an undefined name', () => { + const tool = new EnterWorktreeTool(makeMockConfig()); + expect(tool.validateToolParams({})).toBeNull(); + }); +}); + +describe('ExitWorktreeTool metadata and validation', () => { + it('exposes the correct tool name', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect(tool.name).toBe('exit_worktree'); + expect(tool.displayName).toBe('ExitWorktree'); + }); + + it('requires action to be keep or remove', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect( + tool.validateToolParams({ + name: 'foo', + action: 'destroy' as 'keep' | 'remove', + }), + ).not.toBeNull(); + expect(tool.validateToolParams({ name: 'foo', action: 'keep' })).toBeNull(); + expect( + tool.validateToolParams({ name: 'foo', action: 'remove' }), + ).toBeNull(); + }); + + it('rejects invalid name slugs', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect( + tool.validateToolParams({ name: 'a/b', action: 'remove' }), + ).not.toBeNull(); + }); +}); diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts new file mode 100644 index 00000000000..22bf5baa315 --- /dev/null +++ b/packages/core/src/tools/enter-worktree.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { GitWorktreeService } from '../services/gitWorktreeService.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('ENTER_WORKTREE'); + +export interface EnterWorktreeParams { + /** + * Optional name (slug) for the worktree. Allowed characters: + * letters, digits, dot, underscore, hyphen. Maximum 64 characters. + * If omitted, an auto-generated `{adj}-{noun}-{4hex}` slug is used. + */ + name?: string; +} + +const enterWorktreeDescription = `Creates an isolated git worktree at \`/.qwen/worktrees/\` and returns its absolute path so subsequent file edits, shell commands, and other tools can operate inside it. + +## When to Use + +Only invoke this tool when the user **explicitly asks for a worktree** — e.g. "start a worktree", "use a worktree", "work in a worktree", "create a worktree". + +## When NOT to Use + +Do NOT call this tool when the user simply asks to fix a bug, implement a feature, create a branch, or check out code — those tasks belong to the regular working directory unless the user specifically mentions worktrees. + +## Behavior + +- Requires the current project to be a git repository. +- Creates a new branch \`worktree-\` based on the current branch. +- Returns the absolute \`worktreePath\`. From that point on, route every file path you create or edit through this directory; absolute paths are recommended. +- The worktree persists across the session until \`exit_worktree\` is invoked. +`; + +interface EnterWorktreeOutput { + worktreePath: string; + worktreeBranch: string; + message: string; +} + +class EnterWorktreeInvocation extends BaseToolInvocation< + EnterWorktreeParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: EnterWorktreeParams, + ) { + super(params); + } + + getDescription(): string { + return this.params.name + ? `Enter worktree "${this.params.name}"` + : 'Enter a new worktree'; + } + + async execute(_signal: AbortSignal): Promise { + const projectRoot = this.config.getTargetDir(); + const service = new GitWorktreeService(projectRoot); + + const gitCheck = await service.checkGitAvailable(); + if (!gitCheck.available) { + return errorResult(gitCheck.error ?? 'Git is not available.'); + } + + const isRepo = await service.isGitRepository(); + if (!isRepo) { + return errorResult( + `Cannot create a worktree: ${projectRoot} is not a git repository. Initialize the repo with \`git init\` first.`, + ); + } + + const slug = this.params.name ?? GitWorktreeService.generateAutoSlug(); + const validation = GitWorktreeService.validateUserWorktreeSlug(slug); + if (validation) { + return errorResult(validation); + } + + const result = await service.createUserWorktree(slug); + if (!result.success || !result.worktree) { + return errorResult(result.error ?? 'Failed to create worktree.'); + } + + const output: EnterWorktreeOutput = { + worktreePath: result.worktree.path, + worktreeBranch: result.worktree.branch, + message: + `Created worktree "${slug}" at ${result.worktree.path} on branch ${result.worktree.branch}. ` + + `Use this absolute path for all subsequent file operations until you call ${ToolNames.EXIT_WORKTREE}.`, + }; + + debugLogger.debug( + `Created user worktree: ${output.worktreePath} (branch=${output.worktreeBranch})`, + ); + + return { + llmContent: JSON.stringify(output), + returnDisplay: + `Worktree **${slug}** created on branch \`${result.worktree.branch}\`\n` + + `\`${result.worktree.path}\``, + }; + } +} + +function errorResult(message: string): ToolResult { + return { + llmContent: `Error: ${message}`, + returnDisplay: `Error: ${message}`, + error: { message }, + }; +} + +export class EnterWorktreeTool extends BaseDeclarativeTool< + EnterWorktreeParams, + ToolResult +> { + static readonly Name: string = ToolNames.ENTER_WORKTREE; + + constructor(private readonly config: Config) { + super( + EnterWorktreeTool.Name, + ToolDisplayNames.ENTER_WORKTREE, + enterWorktreeDescription, + Kind.Other, + { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Optional slug (letters, digits, dot, underscore, hyphen; max 64 chars). Auto-generated when omitted.', + }, + }, + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + true, // isOutputMarkdown + false, // canUpdateOutput + ); + } + + override validateToolParams(params: EnterWorktreeParams): string | null { + if (params.name !== undefined) { + if (typeof params.name !== 'string') { + return 'Parameter "name" must be a string.'; + } + const error = GitWorktreeService.validateUserWorktreeSlug(params.name); + if (error) return error; + } + return null; + } + + protected createInvocation(params: EnterWorktreeParams) { + return new EnterWorktreeInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts new file mode 100644 index 00000000000..f265736684a --- /dev/null +++ b/packages/core/src/tools/exit-worktree.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { GitWorktreeService } from '../services/gitWorktreeService.js'; +import * as fs from 'node:fs/promises'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('EXIT_WORKTREE'); + +export interface ExitWorktreeParams { + /** + * The name (slug) of the worktree to exit, as provided to or returned + * by `enter_worktree`. + */ + name: string; + /** + * What to do with the worktree: + * - `'keep'` — leave the worktree directory and branch intact for later use. + * - `'remove'` — delete the worktree directory and branch. + */ + action: 'keep' | 'remove'; + /** + * When `action='remove'`, must be `true` to delete a worktree that has + * uncommitted changes (tracked or untracked). + */ + discard_changes?: boolean; +} + +const exitWorktreeDescription = `Exits a worktree previously created by ${ToolNames.ENTER_WORKTREE}. + +## Behavior + +- \`action='keep'\` — preserves the worktree directory and branch on disk so it can be revisited later. Use when work is in progress and the user might come back to it. +- \`action='remove'\` — deletes the worktree directory and branch. **Refuses to run** if the worktree contains uncommitted changes (tracked or untracked) unless \`discard_changes: true\` is set. Use when the work is committed (or intentionally being discarded). + +## When to Use + +Only invoke this tool when the user explicitly asks to leave or clean up a worktree (e.g. "exit the worktree", "remove that worktree", "we're done with the worktree"). Always pass the same \`name\` that was used with \`${ToolNames.ENTER_WORKTREE}\`. +`; + +interface ExitWorktreeOutput { + action: 'keep' | 'remove'; + worktreePath: string; + worktreeBranch: string; + message: string; +} + +class ExitWorktreeInvocation extends BaseToolInvocation< + ExitWorktreeParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: ExitWorktreeParams, + ) { + super(params); + } + + getDescription(): string { + return this.params.action === 'remove' + ? `Remove worktree "${this.params.name}"` + : `Keep worktree "${this.params.name}"`; + } + + async execute(_signal: AbortSignal): Promise { + const projectRoot = this.config.getTargetDir(); + const service = new GitWorktreeService(projectRoot); + + const worktreePath = service.getUserWorktreePath(this.params.name); + const branch = `worktree-${this.params.name}`; + + // Confirm the worktree directory actually exists before doing anything. + let exists = false; + try { + const stat = await fs.stat(worktreePath); + exists = stat.isDirectory(); + } catch { + exists = false; + } + if (!exists) { + return errorResult( + `Worktree "${this.params.name}" not found at ${worktreePath}.`, + ); + } + + if (this.params.action === 'keep') { + const output: ExitWorktreeOutput = { + action: 'keep', + worktreePath, + worktreeBranch: branch, + message: + `Kept worktree "${this.params.name}" at ${worktreePath}. ` + + `Resume work there by referencing this path in subsequent tool calls.`, + }; + return { + llmContent: JSON.stringify(output), + returnDisplay: `Kept worktree **${this.params.name}** at \`${worktreePath}\``, + }; + } + + // action === 'remove' + if (!this.params.discard_changes) { + const counts = await service.countWorktreeChanges(worktreePath); + if (counts === null) { + return errorResult( + `Cannot inspect worktree "${this.params.name}" to verify it has no changes. ` + + `Pass \`discard_changes: true\` to remove anyway.`, + ); + } + const total = counts.tracked + counts.untracked; + if (total > 0) { + return errorResult( + `Refusing to remove worktree "${this.params.name}" — it has ` + + `${counts.tracked} tracked change(s) and ${counts.untracked} untracked file(s). ` + + `Commit or stash first, or call again with \`discard_changes: true\`.`, + ); + } + } + + const result = await service.removeUserWorktree(this.params.name, { + deleteBranch: true, + }); + if (!result.success) { + return errorResult(result.error ?? 'Failed to remove worktree.'); + } + + debugLogger.debug( + `Removed user worktree: ${worktreePath} (branch=${branch})`, + ); + + const output: ExitWorktreeOutput = { + action: 'remove', + worktreePath, + worktreeBranch: branch, + message: `Removed worktree "${this.params.name}" and deleted branch ${branch}.`, + }; + return { + llmContent: JSON.stringify(output), + returnDisplay: `Removed worktree **${this.params.name}** (branch \`${branch}\`)`, + }; + } +} + +function errorResult(message: string): ToolResult { + return { + llmContent: `Error: ${message}`, + returnDisplay: `Error: ${message}`, + error: { message }, + }; +} + +export class ExitWorktreeTool extends BaseDeclarativeTool< + ExitWorktreeParams, + ToolResult +> { + static readonly Name: string = ToolNames.EXIT_WORKTREE; + + constructor(private readonly config: Config) { + super( + ExitWorktreeTool.Name, + ToolDisplayNames.EXIT_WORKTREE, + exitWorktreeDescription, + Kind.Other, + { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Slug of the worktree to exit (must match the name used in enter_worktree).', + }, + action: { + type: 'string', + enum: ['keep', 'remove'], + description: + '"keep" preserves the worktree on disk; "remove" deletes it and its branch.', + }, + discard_changes: { + type: 'boolean', + description: + 'When action="remove", must be true to delete a worktree with uncommitted changes.', + }, + }, + required: ['name', 'action'], + additionalProperties: false, + $schema: 'http://json-schema.org/draft-07/schema#', + }, + true, // isOutputMarkdown + false, // canUpdateOutput + ); + } + + override validateToolParams(params: ExitWorktreeParams): string | null { + if (typeof params.name !== 'string' || params.name.trim() === '') { + return 'Parameter "name" must be a non-empty string.'; + } + const slugError = GitWorktreeService.validateUserWorktreeSlug(params.name); + if (slugError) return slugError; + + if (params.action !== 'keep' && params.action !== 'remove') { + return 'Parameter "action" must be either "keep" or "remove".'; + } + if ( + params.discard_changes !== undefined && + typeof params.discard_changes !== 'boolean' + ) { + return 'Parameter "discard_changes" must be a boolean.'; + } + return null; + } + + protected createInvocation(params: ExitWorktreeParams) { + return new ExitWorktreeInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 80a78b9028b..25a11bf63e2 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -41,6 +41,8 @@ export const ToolNames = { STRUCTURED_OUTPUT: 'structured_output', MONITOR: 'monitor', TOOL_SEARCH: 'tool_search', + ENTER_WORKTREE: 'enter_worktree', + EXIT_WORKTREE: 'exit_worktree', } as const; /** @@ -72,6 +74,8 @@ export const ToolDisplayNames = { STRUCTURED_OUTPUT: 'StructuredOutput', MONITOR: 'Monitor', TOOL_SEARCH: 'ToolSearch', + ENTER_WORKTREE: 'EnterWorktree', + EXIT_WORKTREE: 'ExitWorktree', } as const; // Migration from old tool names to new tool names From 923f9e43894ae980be588fc5cc9078c4fa5c922c Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 12 May 2026 11:24:52 +0800 Subject: [PATCH 02/15] test(worktree): use path.join in expected paths so the test passes on Windows The Windows CI run reported `enter-worktree.test.ts` failing because the expected string was hardcoded with `/` while `getUserWorktreesDir()` uses `path.join`, which returns `\\` on Windows. Build the expected path via `path.join` so the platform-correct separator is compared. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/tools/enter-worktree.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index 739c871ff8e..096b95d0322 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi } from 'vitest'; +import * as path from 'node:path'; import { EnterWorktreeTool } from './enter-worktree.js'; import { ExitWorktreeTool } from './exit-worktree.js'; import type { Config } from '../config/config.js'; @@ -79,9 +80,14 @@ describe('GitWorktreeService.getUserWorktreesDir / getUserWorktreePath', () => { // Use the cwd (which exists) so simple-git's existence check passes. const root = process.cwd(); const service = new GitWorktreeService(root); - expect(service.getUserWorktreesDir()).toBe(`${root}/.qwen/worktrees`); + // Build expected paths via path.join so the separator matches the + // platform — the implementation uses path.join, so on Windows the + // separator is `\`, not `/`. + expect(service.getUserWorktreesDir()).toBe( + path.join(root, '.qwen', 'worktrees'), + ); expect(service.getUserWorktreePath('feat-x')).toBe( - `${root}/.qwen/worktrees/feat-x`, + path.join(root, '.qwen', 'worktrees', 'feat-x'), ); }); }); From 34311eeb985fceaa60dae8e849ca85ad5a932ab4 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 12 May 2026 14:37:50 +0800 Subject: [PATCH 03/15] fix(enter-worktree): treat empty name as auto-generate Some models pass `{ "name": "" }` when calling EnterWorktree, because the schema marks `name` as optional and they emit an empty placeholder. The previous validation rejected the empty string with "Worktree name must be a non-empty string", which surprised users running the auto-slug path. Now both `validateToolParams` and `execute` treat `name: ""` as equivalent to `name: undefined` and fall back to the auto-generated `{adj}-{noun}-{4hex}` slug. Explicit invalid slugs (`'../etc'`, `'a/b'`, etc.) are still rejected as before. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/tools/enter-worktree.test.ts | 8 ++++++++ packages/core/src/tools/enter-worktree.ts | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index 096b95d0322..f286f05f73b 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -109,6 +109,14 @@ describe('EnterWorktreeTool metadata', () => { const tool = new EnterWorktreeTool(makeMockConfig()); expect(tool.validateToolParams({})).toBeNull(); }); + + it('accepts an empty-string name (treated as auto-generate)', () => { + // Some models pass `{ name: '' }` when the schema marks `name` as + // optional. Validation should not reject this — `execute` falls back + // to an auto-generated slug. + const tool = new EnterWorktreeTool(makeMockConfig()); + expect(tool.validateToolParams({ name: '' })).toBeNull(); + }); }); describe('ExitWorktreeTool metadata and validation', () => { diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts index 22bf5baa315..5b358e052f1 100644 --- a/packages/core/src/tools/enter-worktree.ts +++ b/packages/core/src/tools/enter-worktree.ts @@ -79,7 +79,15 @@ class EnterWorktreeInvocation extends BaseToolInvocation< ); } - const slug = this.params.name ?? GitWorktreeService.generateAutoSlug(); + // Treat an empty `name` ('') the same as undefined — some models pass + // `{ name: '' }` when the schema marks `name` as optional, expecting + // the auto-generated slug. Without this, validation would reject the + // empty string before reaching the auto-slug path. + const requested = + this.params.name && this.params.name.length > 0 + ? this.params.name + : undefined; + const slug = requested ?? GitWorktreeService.generateAutoSlug(); const validation = GitWorktreeService.validateUserWorktreeSlug(slug); if (validation) { return errorResult(validation); @@ -153,6 +161,12 @@ export class EnterWorktreeTool extends BaseDeclarativeTool< if (typeof params.name !== 'string') { return 'Parameter "name" must be a string.'; } + // Empty string is treated as "not provided" — `execute` falls back + // to an auto-generated slug. Skip slug-format validation here so + // the auto-slug path is reachable. + if (params.name.length === 0) { + return null; + } const error = GitWorktreeService.validateUserWorktreeSlug(params.name); if (error) return error; } From 5d07cbe5c8dd15989c2334101c59a7b2df511437 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 12 May 2026 17:04:20 +0800 Subject: [PATCH 04/15] fix(worktree): address review findings 1-6 from PR #4073 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six issues raised on the initial review; each addressed with a verifiable guarantee. 1. Real isolation for `agent isolation: 'worktree'` Before: subagent's Config still resolved `getTargetDir()` to the parent project root, so Edit/Write/Read workspace checks and Shell's default cwd silently operated on the parent tree. The cleanup helper then saw a "clean" worktree and removed it — destroying the evidence. After: the worktree is provisioned BEFORE `createApprovalModeOverride`, and the resulting agent Config has `getTargetDir`/`getCwd`/`getWorkingDir` rebound to the worktree path. Relative paths, unqualified shell commands, and glob/grep roots all confine to the worktree. 2. `exit_worktree action='remove'` now prompts in default/auto-edit modes Added `getDefaultPermission()` on the invocation: `'ask'` when action is `remove`, `'allow'` when `keep`. Brings it in line with edit, write_file, and run_shell_command. 3. Force-delete no longer silently destroys unpushed commits `removeUserWorktree` now uses `git branch -d` (refuses unmerged) by default and surfaces `branchPreserved: true` when git refuses. Added `hasUnmergedWorktreeCommits` (checks if branch tip is reachable from any other local branch or remote ref). Both the agent isolation cleanup and `exit_worktree action='remove'` use this check: if the branch has work not covered elsewhere, the worktree+branch are preserved even when `discard_changes: true` is set (there is no `discard_commits` flag — committed work is rarely what `remove` means to discard). 4. Both new tools are now deferred behind ToolSearch `shouldDefer: true` + `searchHint` on both. Verified via openai-logging: `enter_worktree` and `exit_worktree` no longer appear in the function- declaration list sent on every API request. 5. Stale-worktree cleanup is wired in `Config.initialize()` fires `cleanupStaleAgentWorktrees(targetDir)` as a non-awaited startup sweep (skipped in bare mode). Picks up orphaned `agent-<7hex>` worktrees left by crashed runs. 6. Foreground isolation no longer leaks on uncaught throw The foreground try block tracks whether the cleanup helper ran on the success path; the finally block invokes it as a fallback when the try bailed early. Mirrors the background path's pattern. Verification: - Unit tests: 83 passed (16 worktree + 64 existing agent + 3 cleanup) — no regressions. - E2E #1: agent told to write `hello.txt` via RELATIVE path — file landed at `.qwen/worktrees/agent-XXXXXXX/hello.txt`, NOT at the parent root. - E2E #3: created worktree, committed work inside it, called exit_worktree with `discard_changes=true` — refused with clear message; worktree and branch both preserved. - E2E #4: openai-logging confirms worktree tools absent from API tool list (7 tools sent instead of 9). Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 20 ++ .../core/src/services/gitWorktreeService.ts | 79 ++++++- packages/core/src/tools/agent/agent.ts | 201 ++++++++++++------ .../core/src/tools/enter-worktree.test.ts | 14 ++ packages/core/src/tools/enter-worktree.ts | 3 + packages/core/src/tools/exit-worktree.ts | 59 ++++- 6 files changed, 309 insertions(+), 67 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 307e1848270..e2495fa29b6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1236,6 +1236,26 @@ export class Config { logStartSession(this, new StartSessionEvent(this)); this.debugLogger.info('Config initialization completed'); + + // Fire-and-forget sweep of stale ephemeral worktrees left behind by + // earlier `agent` runs that exited before their cleanup helper ran + // (Ctrl-C, process crash, abrupt shutdown). The sweep only touches + // `agent-<7hex>` slugs, skips anything newer than 30 days, and + // is fail-closed against tracked changes or unpushed commits — so + // running it on every startup cannot destroy user work. We do not + // await this: it is a hygiene task that must never delay the + // first model turn. + if (!this.getBareMode()) { + void import('../services/worktreeCleanup.js') + .then(({ cleanupStaleAgentWorktrees }) => + cleanupStaleAgentWorktrees(this.targetDir), + ) + .catch((error: unknown) => { + this.debugLogger.debug( + `Stale worktree sweep failed (non-fatal): ${error}`, + ); + }); + } } async refreshHierarchicalMemory(): Promise { diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 0652f91dd10..cd663f92ca3 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -951,11 +951,22 @@ export class GitWorktreeService { /** * Removes a user worktree, optionally deleting its branch. + * + * Branch deletion uses `-d` by default (refuses to drop branches that + * have commits not merged into HEAD), so a worktree whose tree was + * left "clean" because the agent committed its work doesn't lose + * those commits when the cleanup helper sweeps it. Set + * `forceDeleteBranch: true` to bypass — callers must have already + * confirmed there is nothing of value on the branch. */ async removeUserWorktree( slug: string, - options: { deleteBranch?: boolean } = {}, - ): Promise<{ success: boolean; error?: string }> { + options: { deleteBranch?: boolean; forceDeleteBranch?: boolean } = {}, + ): Promise<{ + success: boolean; + error?: string; + branchPreserved?: boolean; + }> { const worktreePath = this.getUserWorktreePath(slug); const branchName = `worktree-${slug}`; @@ -964,14 +975,72 @@ export class GitWorktreeService { return removed; } - if (options.deleteBranch) { + if (!options.deleteBranch) { + return { success: true }; + } + + // Try a safe (non-force) delete first. `git branch -d` refuses to + // remove branches whose tip is not reachable from HEAD or any + // upstream — preserving any commits the subagent made before + // ending with a clean working tree. + try { + await this.git.branch(['-d', branchName]); + return { success: true }; + } catch { + // Fall through to forced delete or preservation. + } + + if (options.forceDeleteBranch) { try { await this.git.branch(['-D', branchName]); + return { success: true }; } catch { - // Best-effort: branch may have been deleted already, or may not exist. + // Best-effort: branch may have been deleted already, or may not + // exist (a no-op). } } - return { success: true }; + + // Reached here when the branch had unmerged commits and the caller + // did not opt into force-delete. Surface this so callers can leave + // a note for the user. + return { success: true, branchPreserved: true }; + } + + /** + * Reports whether the tip of a user worktree's branch is reachable + * only from itself — i.e. the branch carries commits that no other + * local branch or remote ref points at, so dropping the branch would + * silently destroy them. Used by callers that want to decide whether + * removing the worktree would lose work the subagent committed but + * never merged or pushed. + * + * Fail-closed: returns `true` on any git error. + */ + async hasUnmergedWorktreeCommits(slug: string): Promise { + const branchName = `worktree-${slug}`; + try { + const tipSha = (await this.git.revparse([branchName])).trim(); + if (!tipSha) return true; + // List every local branch and remote-tracking ref whose tip is at + // or above the worktree branch's tip. If anything other than the + // worktree branch itself appears, the commits are covered. + const refs = ( + await this.git.raw([ + 'for-each-ref', + '--contains', + tipSha, + '--format=%(refname)', + 'refs/heads', + 'refs/remotes', + ]) + ) + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0 && s !== `refs/heads/${branchName}`); + return refs.length === 0; + } catch { + return true; + } } /** diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index dd39db779ae..d65115682f4 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1170,6 +1170,60 @@ class AgentToolInvocation extends BaseToolInvocation { updateOutput(this.currentDisplay); } + // ── Optional worktree isolation (Phase 1: provision) ────────── + // Provision the worktree BEFORE creating the agent Config so the + // override below can rebind `getTargetDir()` to the worktree path + // before the subagent's tools are registered. Without this, + // tools that resolve relative paths via `config.getTargetDir()` + // (Shell default cwd, Edit/Write/Read workspace checks, Glob / + // Grep / Ls roots) would silently operate on the parent project + // tree and the cleanup helper would then see a "clean" worktree + // and remove it — destroying any evidence of the leak. + let worktreeIsolation: { + slug: string; + path: string; + branch: string; + } | null = null; + const failWorktreeProvisioning = (reason: string): ToolResult => { + this.currentDisplay = { + ...this.currentDisplay!, + status: 'failed' as const, + terminateReason: reason, + }; + return { + llmContent: reason, + returnDisplay: this.currentDisplay, + }; + }; + + if (this.params.isolation === 'worktree') { + const projectRoot = this.config.getTargetDir(); + const wtService = new GitWorktreeService(projectRoot); + const gitCheck = await wtService.checkGitAvailable(); + if (!gitCheck.available) { + return failWorktreeProvisioning( + `Failed to set up worktree isolation: ${gitCheck.error ?? 'git is not available'}`, + ); + } + if (!(await wtService.isGitRepository())) { + return failWorktreeProvisioning( + `Failed to set up worktree isolation: ${projectRoot} is not a git repository.`, + ); + } + const slug = `agent-${randomBytes(4).toString('hex').slice(0, 7)}`; + const created = await wtService.createUserWorktree(slug); + if (!created.success || !created.worktree) { + return failWorktreeProvisioning( + `Failed to create isolation worktree: ${created.error ?? 'unknown error'}`, + ); + } + worktreeIsolation = { + slug, + path: created.worktree.path, + branch: created.worktree.branch, + }; + } + // Resolve the subagent's permission mode before creating it const resolvedMode = resolveSubagentApprovalMode( this.config.getApprovalMode(), @@ -1198,6 +1252,25 @@ class AgentToolInvocation extends BaseToolInvocation { resolvedApprovalMode, ); + // ── Optional worktree isolation (Phase 2: rebind cwd) ───────── + // Rebind every "where am I?" getter on the agent's Config override + // to the worktree path. Edit/Write/Read use `getTargetDir()` for + // workspace-membership checks and relative-path resolution; Shell + // uses it as its default cwd when the model omits `directory`; + // Glob/Grep/Ls use it as the search root. Overriding all three + // getters (targetDir/cwd/workingDir) on the prototype-delegation + // Config keeps the subagent's tools confined to the worktree + // even when the model emits relative paths or unqualified shell + // commands. + if (worktreeIsolation) { + const wtPath = worktreeIsolation.path; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ov = agentConfig as any; + ov.getTargetDir = () => wtPath; + ov.getCwd = () => wtPath; + ov.getWorkingDir = () => wtPath; + } + // Create the subagent. Fork bypasses SubagentManager because its // runtime configs are synthesized from the parent's cache-safe params. let subagent: AgentHeadless; @@ -1216,90 +1289,75 @@ class AgentToolInvocation extends BaseToolInvocation { taskPrompt = this.params.prompt; } - // ── Optional worktree isolation ────────────────────────────── - // When `isolation: 'worktree'` is requested, provision a temporary - // worktree under /.qwen/worktrees/agent-<7hex> and - // prepend a notice to the task prompt telling the subagent to - // operate inside that path. After the subagent completes, the - // cleanup helper removes the worktree if no changes were made, - // or preserves it (and reports its path/branch) if there are. - let worktreeIsolation: { - slug: string; - path: string; - branch: string; - } | null = null; - const failWorktreeProvisioning = (reason: string): ToolResult => { - this.currentDisplay = { - ...this.currentDisplay!, - status: 'failed' as const, - terminateReason: reason, - }; - return { - llmContent: reason, - returnDisplay: this.currentDisplay, - }; - }; - - if (this.params.isolation === 'worktree') { - const projectRoot = this.config.getTargetDir(); - const wtService = new GitWorktreeService(projectRoot); - const gitCheck = await wtService.checkGitAvailable(); - if (!gitCheck.available) { - return failWorktreeProvisioning( - `Failed to set up worktree isolation: ${gitCheck.error ?? 'git is not available'}`, - ); - } - if (!(await wtService.isGitRepository())) { - return failWorktreeProvisioning( - `Failed to set up worktree isolation: ${projectRoot} is not a git repository.`, - ); - } - const slug = `agent-${randomBytes(4).toString('hex').slice(0, 7)}`; - const created = await wtService.createUserWorktree(slug); - if (!created.success || !created.worktree) { - return failWorktreeProvisioning( - `Failed to create isolation worktree: ${created.error ?? 'unknown error'}`, - ); - } - worktreeIsolation = { - slug, - path: created.worktree.path, - branch: created.worktree.branch, - }; - const notice = buildWorktreeNotice(projectRoot, created.worktree.path); + // ── Optional worktree isolation (Phase 3: notice to prompt) ─── + // Prepend a notice to the task prompt telling the subagent it is + // operating in an isolated worktree. The mechanical isolation + // above guarantees correctness; the notice reduces user-visible + // surprises when the model summarises file paths. + if (worktreeIsolation) { + const notice = buildWorktreeNotice( + this.config.getTargetDir(), + worktreeIsolation.path, + ); taskPrompt = `${notice}\n\n${taskPrompt}`; } // Cleanup helper invoked after the subagent terminates (success, - // failure, or cancel). Removes the worktree when no changes were - // made; preserves it (and returns its info) when there are changes. + // failure, or cancel). + // + // Two independent reasons to preserve the worktree: + // 1. The working tree is not clean (uncommitted edits). + // 2. The branch carries commits not reachable from any other + // ref — `git status` would call this "clean", but dropping + // the worktree+branch would silently destroy the commits. + // + // Removing the worktree always uses `git branch -d` (safe delete); + // if git refuses because of unmerged commits, `branchPreserved` + // is surfaced so the caller can tell the user. const cleanupWorktreeIsolation = async (): Promise<{ preservedPath?: string; preservedBranch?: string; }> => { if (!worktreeIsolation) return {}; const wtService = new GitWorktreeService(this.config.getTargetDir()); + const isolation = worktreeIsolation; let hasChanges = true; try { - hasChanges = await wtService.hasWorktreeChanges( - worktreeIsolation.path, - ); + hasChanges = await wtService.hasWorktreeChanges(isolation.path); } catch { // Fail-closed: assume changes exist. } - if (hasChanges) { + let hasUnmerged = true; + try { + hasUnmerged = await wtService.hasUnmergedWorktreeCommits( + isolation.slug, + ); + } catch { + // Fail-closed: assume there is uncovered work. + } + if (hasChanges || hasUnmerged) { return { - preservedPath: worktreeIsolation.path, - preservedBranch: worktreeIsolation.branch, + preservedPath: isolation.path, + preservedBranch: isolation.branch, }; } try { - await wtService.removeUserWorktree(worktreeIsolation.slug, { + const result = await wtService.removeUserWorktree(isolation.slug, { deleteBranch: true, }); + if (result.branchPreserved) { + // The status check said "clean" and the ref check said + // "fully covered", but the safe-delete still refused — + // a race or an oddly-shaped ref. Be loud rather than + // silently force-deleting. + return { + preservedPath: isolation.path, + preservedBranch: isolation.branch, + }; + } } catch (error) { debugLogger.warn( - `[Agent] Failed to remove ephemeral worktree ${worktreeIsolation.path}: ${error}`, + `[Agent] Failed to remove ephemeral worktree ${isolation.path}: ${error}`, ); } return {}; @@ -1813,11 +1871,19 @@ class AgentToolInvocation extends BaseToolInvocation { this.eventEmitter.on(AgentEventType.TOOL_CALL, onFgToolCall); this.eventEmitter.on(AgentEventType.USAGE_METADATA, onFgUsageMetadata); + // Tracks whether the cleanup helper has already run, so the + // finally block can skip a duplicate call when the try block + // already ran it. Without this, a worktree provisioned for an + // isolation run would leak when `runFramed()` throws — the catch + // path is the outer execute() catch, which is too far away to + // see the closure. + let worktreeCleanupRan = false; try { await runFramed(); const finalText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); const wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); + worktreeCleanupRan = true; if (terminateMode === AgentTerminateMode.ERROR) { return { llmContent: (finalText || 'Subagent execution failed.') + wtSuffix, @@ -1849,6 +1915,19 @@ class AgentToolInvocation extends BaseToolInvocation { returnDisplay: this.currentDisplay!, }; } finally { + // Mirror the background path: ensure the isolation worktree is + // reaped on every termination shape (success, failure, cancel, + // and any uncaught throw inside runFramed). The success path + // already invoked the helper; this fallback only fires when + // the try block bailed before reaching it. + if (!worktreeCleanupRan) { + try { + await cleanupWorktreeIsolation(); + } catch { + // Helper logs its own failures; never mask the original + // error path with cleanup noise. + } + } this.eventEmitter.off(AgentEventType.TOOL_CALL, onFgToolCall); this.eventEmitter.off(AgentEventType.USAGE_METADATA, onFgUsageMetadata); signal?.removeEventListener('abort', onParentAbort); diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index f286f05f73b..5f5bbabc66d 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -119,6 +119,20 @@ describe('EnterWorktreeTool metadata', () => { }); }); +describe('ExitWorktreeTool default permission', () => { + it("returns 'ask' when action is 'remove'", async () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const inv = tool.build({ name: 'foo', action: 'remove' }); + expect(await inv.getDefaultPermission()).toBe('ask'); + }); + + it("returns 'allow' when action is 'keep'", async () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const inv = tool.build({ name: 'foo', action: 'keep' }); + expect(await inv.getDefaultPermission()).toBe('allow'); + }); +}); + describe('ExitWorktreeTool metadata and validation', () => { it('exposes the correct tool name', () => { const tool = new ExitWorktreeTool(makeMockConfig()); diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts index 5b358e052f1..ea9448b374d 100644 --- a/packages/core/src/tools/enter-worktree.ts +++ b/packages/core/src/tools/enter-worktree.ts @@ -153,6 +153,9 @@ export class EnterWorktreeTool extends BaseDeclarativeTool< }, true, // isOutputMarkdown false, // canUpdateOutput + true, // shouldDefer — only invoked when the user explicitly asks for a worktree + false, // alwaysLoad + 'worktree git isolated branch new', ); } diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index f265736684a..f17af414d7d 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -7,6 +7,7 @@ import type { ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { Config } from '../config/config.js'; +import type { PermissionDecision } from '../permissions/types.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { GitWorktreeService } from '../services/gitWorktreeService.js'; import * as fs from 'node:fs/promises'; @@ -69,6 +70,17 @@ class ExitWorktreeInvocation extends BaseToolInvocation< : `Keep worktree "${this.params.name}"`; } + /** + * `action: 'remove'` deletes a worktree directory and (when safe) its + * branch. Other destructive tools (`edit`, `write_file`, + * `run_shell_command`) prompt by default; this tool should too. The + * `keep` action is non-destructive (it only restores the original + * working directory) and falls back to the framework default. + */ + override async getDefaultPermission(): Promise { + return this.params.action === 'remove' ? 'ask' : 'allow'; + } + async execute(_signal: AbortSignal): Promise { const projectRoot = this.config.getTargetDir(); const service = new GitWorktreeService(projectRoot); @@ -105,7 +117,16 @@ class ExitWorktreeInvocation extends BaseToolInvocation< }; } - // action === 'remove' + // action === 'remove' — two independent guards: + // + // 1. Uncommitted edits (working tree dirty). Bypassed by + // `discard_changes: true`. + // 2. Commits on the worktree branch that no other local branch or + // remote ref points at. Deleting the branch would lose them, so + // we refuse unconditionally — the user must merge, push, or + // rename the branch elsewhere first. There is no "discard + // commits" flag because losing committed work is rarely what the + // user means by "remove worktree". if (!this.params.discard_changes) { const counts = await service.countWorktreeChanges(worktreePath); if (counts === null) { @@ -124,12 +145,45 @@ class ExitWorktreeInvocation extends BaseToolInvocation< } } + let hasUnmerged = true; + try { + hasUnmerged = await service.hasUnmergedWorktreeCommits(this.params.name); + } catch { + // Fall through; surfaced as the conservative "refuse" path below. + } + if (hasUnmerged) { + return errorResult( + `Refusing to remove worktree "${this.params.name}" — its branch ` + + `\`${branch}\` has commits that no other branch or remote ref ` + + `points at, and deleting the branch would lose them. Merge, ` + + `push, or rename the branch first, then call ${ToolNames.EXIT_WORKTREE} again.`, + ); + } + const result = await service.removeUserWorktree(this.params.name, { deleteBranch: true, }); if (!result.success) { return errorResult(result.error ?? 'Failed to remove worktree.'); } + if (result.branchPreserved) { + // Status check passed and unmerged check passed, but the safe + // delete still refused — most likely a race where new commits + // landed between the checks. Be loud rather than force-deleting. + const output: ExitWorktreeOutput = { + action: 'remove', + worktreePath, + worktreeBranch: branch, + message: + `Removed worktree directory "${this.params.name}" but kept branch ${branch} ` + + `(git refused a safe delete at the last moment — possibly a race with another ` + + `process). Recover with \`git branch -D ${branch}\` if you really want to discard it.`, + }; + return { + llmContent: JSON.stringify(output), + returnDisplay: `Removed worktree directory **${this.params.name}**, branch \`${branch}\` preserved`, + }; + } debugLogger.debug( `Removed user worktree: ${worktreePath} (branch=${branch})`, @@ -194,6 +248,9 @@ export class ExitWorktreeTool extends BaseDeclarativeTool< }, true, // isOutputMarkdown false, // canUpdateOutput + true, // shouldDefer — only invoked when the user explicitly asks to leave a worktree + false, // alwaysLoad + 'worktree exit leave remove keep cleanup', ); } From d777ff37f1ac586ae4966c45f4617f58b4cbb287 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 10:29:26 +0800 Subject: [PATCH 05/15] fix(worktree): address review round 2 findings (1 from tanzhenxin, 7+8 from wenshao) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first round closed the data-loss-class issues. This round addresses follow-ups from a deeper audit: 1. Stale-worktree sweep was inert on common-case repos `cleanupStaleAgentWorktrees` previously ran `git log --branches --not --remotes --oneline` from each worktree's directory — that lists unpushed commits across EVERY local branch, not just the worktree's own branch. On any repo with no remote configured (or with stray unpushed branches), the sweep refused to remove every candidate. Replaced with `service.hasUnmergedWorktreeCommits(slug)` which scopes the check to the worktree branch via `for-each-ref --contains `. Also added the `branchPreserved` warn log requested in M7 and an `fs.access` shortcut for the empty-worktrees-dir case (M8). 2. `cleanupWorktreeIsolation` and `worktreeIsolation` were inside the inner try (~660 lines from the outer catch). Hoisted both to the top of `execute()` so the outer catch can reap or preserve the worktree when anything between provisioning and the inner try throws (e.g. `createApprovalModeOverride`, agent creation). Closure carries the resolved `repoRoot` so cleanup never has to re-resolve. 3. Background error path discarded the cleanup result. Now captures `formatWorktreeSuffix(...)` and appends it to the registry's failure /cancel message, so users see the preserved path/branch even when the agent crashed before reporting. 4. `cleanupWorktreeIsolation` now treats `result.success === false` as "worktree still on disk" and surfaces it as preserved instead of silently dropping it from the result. 5. Override was incomplete. Several Config methods read `this.targetDir` directly (`getProjectRoot`, `getFileService`, etc.) — own-property getter overrides did not redirect them. Now also shadows `targetDir` and `cwd` as own properties on the agent's Config override, swaps in a `FileDiscoveryService` rooted at the worktree, and rebuilds `WorkspaceContext` to point at the worktree only. Verified end-to-end: shell `pwd > pwd-record.txt` (no directory arg) lands at `.qwen/worktrees/agent-<7hex>/pwd-record.txt`, not the parent root. 6. monorepo subdir issue. Both `enter_worktree` and the agent isolation path now resolve `git rev-parse --show-toplevel` first and anchor `.qwen/worktrees/` at the repo root. Worktrees created from any subdirectory now end up where the startup sweep can find them. 7. Replaced `git worktree add -B` (silent force-reset of pre-existing branches) with `git worktree add -b` plus an explicit existence check via `git for-each-ref` (NOT `show-ref --quiet`, which simple-git swallows). Pre-existing `worktree-` branches now trigger a clear error instead of clobbering committed work. 8. First worktree creation in a repo writes `/.qwen/.gitignore` with `worktrees/` so worktree contents stay out of the parent's `git status`, glob/grep results, and bundle tools. Idempotent: never overwrites an existing file. 9. Logging across the failure paths (`enter_worktree` errors, `agent.ts:failWorktreeProvisioning`, `cleanupWorktreeIsolation`, `hasUnmergedWorktreeCommits` swallowed errors, `cleanupStaleAgentWorktrees`'s `branchPreserved` race). 10. `exit_worktree` no longer suggests `discard_changes: true` when the git status check itself fails — that would be advising the user to bypass a safety check whose precondition is unknown. Now points at the underlying repo problem. 11. `generateAutoSlug` switched from `Math.random()` (4 hex, weak RNG, one-in-65k collision) to `randomBytes` (6 hex, ~16M combinations). Two RNG sources in this file collapsed to one. Pushed back: the TOCTOU swap in `removeUserWorktree` (S6 round 1) is left as-is — `git branch -d` is the real safety, and reordering does not eliminate the window. Windows reserved-name validation (M5 round 2) deferred to a follow-up; the current allowlist already rejects path separators, `..`, leading dot/dash, and the >64-char case. Co-Authored-By: Claude Opus 4.7 --- .../core/src/services/gitWorktreeService.ts | 162 +++++++++- packages/core/src/services/worktreeCleanup.ts | 56 ++-- packages/core/src/tools/agent/agent.ts | 295 ++++++++++++------ .../core/src/tools/enter-worktree.test.ts | 18 +- packages/core/src/tools/enter-worktree.ts | 36 ++- packages/core/src/tools/exit-worktree.ts | 10 +- 6 files changed, 424 insertions(+), 153 deletions(-) diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index cd663f92ca3..cf36b6a1101 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -6,14 +6,18 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { randomBytes } from 'node:crypto'; import { execSync } from 'node:child_process'; import { simpleGit, CheckRepoActions } from 'simple-git'; import type { SimpleGit } from 'simple-git'; import { Storage } from '../config/storage.js'; import { isCommandAvailable } from '../utils/shell-utils.js'; import { isNodeError } from '../utils/errors.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { initRepositoryWithMainBranch } from './gitInit.js'; +const debugLogger = createDebugLogger('GIT_WORKTREE_SERVICE'); + /** * Commit message used for the baseline snapshot in worktrees. * After overlaying the user's dirty state (tracked changes + untracked files), @@ -152,6 +156,27 @@ export class GitWorktreeService { return { available: true }; } + /** + * Resolves the absolute path of the enclosing git repository's top + * directory. Used by callers that need to anchor general-purpose + * worktrees at the *repo* root rather than the cwd they were invoked + * from — otherwise running `qwen` from a monorepo subdirectory would + * scatter `.qwen/worktrees/` under each subdirectory instead of + * gathering them under the repo root. + * + * Returns the canonical top-level path on success, or `null` when the + * cwd is not inside a git repo (caller should error). + */ + async getRepoTopLevel(): Promise { + try { + const out = await this.git.revparse(['--show-toplevel']); + const top = out.trim(); + return top.length > 0 ? top : null; + } catch { + return null; + } + } + /** * Checks if the source path is a git repository. */ @@ -848,7 +873,13 @@ export class GitWorktreeService { } /** - * Generates an auto-slug `{adj}-{noun}-{4hex}` for an unnamed worktree. + * Generates an auto-slug `{adj}-{noun}-{6hex}` for an unnamed worktree. + * + * Uses `randomBytes` (the same source as agent slugs) so the two + * RNG paths in this file stay consistent and so the suffix is genuinely + * unpredictable rather than `Math.random()`-derived. The 6-hex suffix + * yields ~16M combinations × 8 adj × 8 noun ≈ 1B distinct slugs, + * eliminating the practical collision concern called out in review. */ static generateAutoSlug(): string { const ADJECTIVES = [ @@ -862,9 +893,10 @@ export class GitWorktreeService { 'quick', ]; const NOUNS = ['fox', 'owl', 'elm', 'oak', 'ray', 'sky', 'leaf', 'pine']; - const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; - const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; - const suffix = Math.random().toString(16).slice(2, 6).padEnd(4, '0'); + const bytes = randomBytes(4); + const adj = ADJECTIVES[bytes[0] % ADJECTIVES.length]; + const noun = NOUNS[bytes[1] % NOUNS.length]; + const suffix = bytes.toString('hex').slice(0, 6); return `${adj}-${noun}-${suffix}`; } @@ -896,6 +928,12 @@ export class GitWorktreeService { * Creates a general-purpose worktree at `/.qwen/worktrees/` * with branch `worktree-`. Used by `EnterWorktreeTool` and * `AgentTool isolation:'worktree'`. + * + * Refuses to overwrite an existing branch: if `worktree-` already + * exists (e.g., from a manual `git checkout -b worktree-foo` or a + * teammate's push), the call fails with a clear error rather than + * silently resetting the branch. The previous `-B` form would have + * dropped any commits unique to that branch — see review #4073. */ async createUserWorktree( slug: string, @@ -903,6 +941,9 @@ export class GitWorktreeService { ): Promise { const validationError = GitWorktreeService.validateUserWorktreeSlug(slug); if (validationError) { + debugLogger.warn( + `createUserWorktree: invalid slug ${slug}: ${validationError}`, + ); return { success: false, error: validationError }; } @@ -912,21 +953,38 @@ export class GitWorktreeService { const worktreePath = path.join(worktreesDir, slug); if (await this.pathExists(worktreePath)) { - return { - success: false, - error: `Worktree already exists at ${worktreePath}`, - }; + const error = `Worktree already exists at ${worktreePath}`; + debugLogger.warn(`createUserWorktree: ${error}`); + return { success: false, error }; } + // Keep the worktrees directory and its contents out of the parent + // repo's `git status` and any subsequent glob/grep that walks from + // the parent root. Only writes when the file is missing — never + // touches an existing user-managed `.qwen/.gitignore`. + await this.ensureWorktreesGitignored(); + const base = baseBranch || (await this.getCurrentBranch()); const branchName = `worktree-${slug}`; - // `-B` resets the branch if it already exists (e.g., from a previous - // worktree at the same slug that was removed without deleting the branch). + // Refuse to clobber a pre-existing branch with the same name. Use + // `git show-ref --verify --quiet refs/heads/` (exit 0 → + // branch exists). The previous `-B` form would have force-reset + // such a branch and silently dropped unmerged commits. + const branchExists = await this.localBranchExists(branchName); + if (branchExists) { + const error = + `Cannot create worktree "${slug}": branch ${branchName} already exists. ` + + `Choose a different name, or delete the branch first ` + + `(e.g. \`git branch -d ${branchName}\`).`; + debugLogger.warn(`createUserWorktree: ${error}`); + return { success: false, error }; + } + await this.git.raw([ 'worktree', 'add', - '-B', + '-b', branchName, worktreePath, base, @@ -942,10 +1000,72 @@ export class GitWorktreeService { }; return { success: true, worktree }; } catch (error) { - return { - success: false, - error: `Failed to create worktree "${slug}": ${error instanceof Error ? error.message : 'Unknown error'}`, - }; + const message = `Failed to create worktree "${slug}": ${error instanceof Error ? error.message : 'Unknown error'}`; + debugLogger.warn(`createUserWorktree: ${message}`); + return { success: false, error: message }; + } + } + + /** + * Returns true if a local branch with the given name exists. + * + * Uses `for-each-ref` because `simple-git.raw` swallows the non-zero + * exit of `show-ref --quiet` and always resolves with empty stdout — + * so the previous `show-ref` form would always return `true` and + * permanently block worktree creation. `for-each-ref` instead prints + * the ref name when it exists and prints nothing when it does not, + * always exiting 0, so we can decide on the output. + * + * Conservative on error: returns false so the caller's "not exists" + * fast path attempts the create (which itself will fail loudly if the + * branch exists for some reason this check missed). + */ + private async localBranchExists(branchName: string): Promise { + try { + const out = await this.git.raw([ + 'for-each-ref', + '--count=1', + '--format=%(refname)', + `refs/heads/${branchName}`, + ]); + return out.trim().length > 0; + } catch { + return false; + } + } + + /** + * Ensures `/.qwen/.gitignore` ignores the worktrees + * directory. Idempotent: writes only when the file is missing. If the + * file exists (user may have curated it), this method is a no-op so + * we never disturb intentional configuration. + */ + private async ensureWorktreesGitignored(): Promise { + try { + const qwenDir = path.join(this.sourceRepoPath, '.qwen'); + await fs.mkdir(qwenDir, { recursive: true }); + const gitignorePath = path.join(qwenDir, '.gitignore'); + try { + await fs.access(gitignorePath); + return; + } catch { + // File missing — fall through to write a default. + } + await fs.writeFile( + gitignorePath, + // Keep ephemeral worktrees out of the parent repo's git status, + // glob/grep results, and bundling tools. Users can edit this + // file freely; we only write it when it does not already exist. + `# Auto-generated by qwen-code.\n${WORKTREES_DIR}/\n`, + 'utf8', + ); + } catch (error) { + // Best-effort: if writing the gitignore fails (read-only fs, etc.) + // it is not worth aborting the worktree creation. Surface in debug + // logs only. + debugLogger.warn( + `ensureWorktreesGitignored failed (non-fatal): ${error}`, + ); } } @@ -1014,7 +1134,8 @@ export class GitWorktreeService { * removing the worktree would lose work the subagent committed but * never merged or pushed. * - * Fail-closed: returns `true` on any git error. + * Fail-closed: returns `true` on any git error so the caller defaults + * to preserving rather than destroying the worktree. */ async hasUnmergedWorktreeCommits(slug: string): Promise { const branchName = `worktree-${slug}`; @@ -1038,7 +1159,14 @@ export class GitWorktreeService { .map((s) => s.trim()) .filter((s) => s.length > 0 && s !== `refs/heads/${branchName}`); return refs.length === 0; - } catch { + } catch (error) { + // Fail-closed but log so a corrupted ref store or permission + // problem can be diagnosed: without this, callers see the + // conservative "has unmerged commits" reply with no clue about + // the underlying git failure. + debugLogger.warn( + `hasUnmergedWorktreeCommits failed for slug ${slug}: ${error}`, + ); return true; } } diff --git a/packages/core/src/services/worktreeCleanup.ts b/packages/core/src/services/worktreeCleanup.ts index e7466d6dadf..b25b46d2f23 100644 --- a/packages/core/src/services/worktreeCleanup.ts +++ b/packages/core/src/services/worktreeCleanup.ts @@ -10,6 +10,11 @@ import { simpleGit } from 'simple-git'; import { GitWorktreeService } from './gitWorktreeService.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +// Branch name produced by `GitWorktreeService.createUserWorktree`: the +// directory slug prefixed with `worktree-`. Re-derived here rather than +// imported to keep this module's only external dep on the service light. +const branchNameForSlug = (slug: string): string => `worktree-${slug}`; + const debugLogger = createDebugLogger('WORKTREE_CLEANUP'); /** @@ -57,6 +62,16 @@ export async function cleanupStaleAgentWorktrees( const service = new GitWorktreeService(projectRoot); const worktreesDir = service.getUserWorktreesDir(); + // Fast bail-out for the common case (user has never used worktrees): + // skip the dynamic readdir entirely instead of relying on the catch + // path's ENOENT handler, which preserves the original stack on any + // other I/O error. + try { + await fs.access(worktreesDir); + } catch { + return 0; + } + let entries; try { entries = await fs.readdir(worktreesDir, { withFileTypes: true }); @@ -84,21 +99,32 @@ export async function cleanupStaleAgentWorktrees( } if (mtimeMs >= cutoffDate) continue; - // Fail-closed: any sign of in-progress work or unpushed commits → keep. + // Fail-closed: any sign of in-progress work or unmerged commits → keep. if (await hasTrackedChanges(worktreePath)) continue; - if (await hasUnpushedCommits(worktreePath)) continue; + if (await service.hasUnmergedWorktreeCommits(entry.name)) continue; const result = await service.removeUserWorktree(entry.name, { deleteBranch: true, }); - if (result.success) { - removed += 1; - debugLogger.debug(`Removed stale agent worktree ${worktreePath}`); - } else { + if (!result.success) { debugLogger.warn( `Failed to remove stale agent worktree ${worktreePath}: ${result.error}`, ); + continue; } + if (result.branchPreserved) { + // Race: commits landed between hasUnmergedWorktreeCommits and + // git branch -d. The directory is gone but the branch remains so + // those commits can still be recovered. Surface it so an operator + // grepping logs can spot orphan branches. + debugLogger.warn( + `Removed stale agent worktree ${worktreePath} but kept branch ` + + `${branchNameForSlug(entry.name)} (unmerged commits at delete time)`, + ); + } else { + debugLogger.debug(`Removed stale agent worktree ${worktreePath}`); + } + removed += 1; } if (removed > 0) { @@ -130,22 +156,4 @@ async function hasTrackedChanges(worktreePath: string): Promise { } } -async function hasUnpushedCommits(worktreePath: string): Promise { - try { - const wtGit = simpleGit(worktreePath); - // Empty output → all commits have a remote ref above them. - // Non-empty output → at least one commit not reachable from any remote. - const out = await wtGit.raw([ - 'log', - '--branches', - '--not', - '--remotes', - '--oneline', - ]); - return out.trim().length > 0; - } catch { - return true; - } -} - export const __test__ = { isEphemeralSlug }; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index d65115682f4..28d6d0b53eb 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -44,6 +44,8 @@ import { } from './fork-subagent.js'; import { randomBytes } from 'node:crypto'; import { GitWorktreeService } from '../../services/gitWorktreeService.js'; +import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; +import { WorkspaceContext } from '../../utils/workspaceContext.js'; import { getCurrentAgentId, runWithAgentContext, @@ -1111,6 +1113,108 @@ class AgentToolInvocation extends BaseToolInvocation { signal?: AbortSignal, updateOutput?: (output: ToolResultDisplay) => void, ): Promise { + // ── Isolation state hoisted to the outermost scope ──────────── + // The outer try/catch in this method is the last line of defence + // against pre-execution failures (e.g. createApprovalModeOverride + // throws). If `worktreeIsolation` and `cleanupWorktreeIsolation` + // lived inside the try, the catch would have no way to reach them, + // and a provisioned worktree would leak until the 30-day startup + // sweep — review #4073 round 2. + let worktreeIsolation: { + slug: string; + path: string; + branch: string; + repoRoot: string; + } | null = null; + + const cleanupWorktreeIsolation = async (): Promise<{ + preservedPath?: string; + preservedBranch?: string; + }> => { + if (!worktreeIsolation) return {}; + const isolation = worktreeIsolation; + const wtService = new GitWorktreeService(isolation.repoRoot); + let hasChanges = true; + try { + hasChanges = await wtService.hasWorktreeChanges(isolation.path); + } catch (error) { + debugLogger.warn( + `[Agent] hasWorktreeChanges failed for ${isolation.path}: ${error}`, + ); + } + let hasUnmerged = true; + try { + hasUnmerged = await wtService.hasUnmergedWorktreeCommits( + isolation.slug, + ); + } catch (error) { + debugLogger.warn( + `[Agent] hasUnmergedWorktreeCommits failed for ${isolation.slug}: ${error}`, + ); + } + if (hasChanges || hasUnmerged) { + debugLogger.info( + `[Agent] Preserving isolation worktree ${isolation.path} ` + + `(branch ${isolation.branch}, hasChanges=${hasChanges}, hasUnmerged=${hasUnmerged})`, + ); + return { + preservedPath: isolation.path, + preservedBranch: isolation.branch, + }; + } + try { + const result = await wtService.removeUserWorktree(isolation.slug, { + deleteBranch: true, + }); + if (!result.success) { + // Removal itself failed (could not delete the directory). The + // worktree is still on disk — do NOT silently drop it from + // the user's view. Surface as preserved so they can recover. + debugLogger.warn( + `[Agent] Failed to remove ephemeral worktree ${isolation.path}: ${result.error}`, + ); + return { + preservedPath: isolation.path, + preservedBranch: isolation.branch, + }; + } + if (result.branchPreserved) { + // Status check said "clean" and the unmerged check said "fully + // covered", but the safe-delete still refused — most likely a + // race where commits landed between the checks and the delete. + // Be loud rather than silently force-deleting. + debugLogger.warn( + `[Agent] Removed worktree ${isolation.path} but kept branch ` + + `${isolation.branch} (unmerged commits at delete time)`, + ); + return { + preservedPath: isolation.path, + preservedBranch: isolation.branch, + }; + } + } catch (error) { + debugLogger.warn( + `[Agent] Failed to remove ephemeral worktree ${isolation.path}: ${error}`, + ); + return { + preservedPath: isolation.path, + preservedBranch: isolation.branch, + }; + } + return {}; + }; + + const formatWorktreeSuffix = (info: { + preservedPath?: string; + preservedBranch?: string; + }): string => { + if (!info.preservedPath) return ''; + return ( + `\n\n[worktree preserved: ${info.preservedPath} ` + + `(branch ${info.preservedBranch ?? 'unknown'})]` + ); + }; + try { const isFork = !this.params.subagent_type; let subagentConfig: SubagentConfig; @@ -1179,12 +1283,8 @@ class AgentToolInvocation extends BaseToolInvocation { // Grep / Ls roots) would silently operate on the parent project // tree and the cleanup helper would then see a "clean" worktree // and remove it — destroying any evidence of the leak. - let worktreeIsolation: { - slug: string; - path: string; - branch: string; - } | null = null; const failWorktreeProvisioning = (reason: string): ToolResult => { + debugLogger.warn(`[Agent] worktree isolation failed: ${reason}`); this.currentDisplay = { ...this.currentDisplay!, status: 'failed' as const, @@ -1197,19 +1297,25 @@ class AgentToolInvocation extends BaseToolInvocation { }; if (this.params.isolation === 'worktree') { - const projectRoot = this.config.getTargetDir(); - const wtService = new GitWorktreeService(projectRoot); - const gitCheck = await wtService.checkGitAvailable(); + const cwd = this.config.getTargetDir(); + const probe = new GitWorktreeService(cwd); + const gitCheck = await probe.checkGitAvailable(); if (!gitCheck.available) { return failWorktreeProvisioning( `Failed to set up worktree isolation: ${gitCheck.error ?? 'git is not available'}`, ); } - if (!(await wtService.isGitRepository())) { + if (!(await probe.isGitRepository())) { return failWorktreeProvisioning( - `Failed to set up worktree isolation: ${projectRoot} is not a git repository.`, + `Failed to set up worktree isolation: ${cwd} is not a git repository.`, ); } + // Anchor the worktree at the repo top-level so monorepo subdir + // launches still gather worktrees under `/.qwen/...`, + // which is also the path the startup sweep scans. + const projectRoot = (await probe.getRepoTopLevel()) ?? cwd; + const wtService = + projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); const slug = `agent-${randomBytes(4).toString('hex').slice(0, 7)}`; const created = await wtService.createUserWorktree(slug); if (!created.success || !created.worktree) { @@ -1221,6 +1327,7 @@ class AgentToolInvocation extends BaseToolInvocation { slug, path: created.worktree.path, branch: created.worktree.branch, + repoRoot: projectRoot, }; } @@ -1253,22 +1360,56 @@ class AgentToolInvocation extends BaseToolInvocation { ); // ── Optional worktree isolation (Phase 2: rebind cwd) ───────── - // Rebind every "where am I?" getter on the agent's Config override - // to the worktree path. Edit/Write/Read use `getTargetDir()` for - // workspace-membership checks and relative-path resolution; Shell - // uses it as its default cwd when the model omits `directory`; - // Glob/Grep/Ls use it as the search root. Overriding all three - // getters (targetDir/cwd/workingDir) on the prototype-delegation - // Config keeps the subagent's tools confined to the worktree - // even when the model emits relative paths or unqualified shell - // commands. + // Rebind every "where am I?" surface on the agent's Config + // override to the worktree path so the subagent's tools cannot + // leak into the parent project tree. + // + // Two layers of override are needed because Config getters mix + // "read the field" and "read the getter": + // + // 1. Field shadow on `targetDir` and `cwd`. Several Config methods + // (e.g. `getProjectRoot`, `getFileService`, + // `cleanupStaleAgentWorktrees(this.targetDir)` paths) read + // `this.targetDir` directly rather than going through a getter. + // `Object.create(base)` does not shadow fields without an + // own-property assignment, so any direct read still resolves + // via the prototype chain to the parent's `targetDir`. Setting + // `ov.targetDir` (and `ov.cwd`) shadows the field for those + // paths. + // + // 2. Getter overrides on `getTargetDir`/`getCwd`/`getWorkingDir`/ + // `getProjectRoot` for the same reason in reverse: they call + // `this.targetDir` on the override, which is what we want, but + // explicit getters defined on `base` would still call methods + // on `this` that read other fields. Overriding the getters + // keeps the contract obvious to grep. + // + // 3. `getFileService()` returns a memoised `FileDiscoveryService` + // constructed against the parent's `targetDir`. We must + // return a service rooted at the worktree so .gitignore / + // .qwenignore filtering treats the worktree as the project + // root. + // + // 4. `getWorkspaceContext()` is the membership check used by + // Edit/Write/Read to refuse out-of-workspace edits. We rebuild + // it pointing at the worktree only so the subagent cannot + // write to absolute paths under the parent project. if (worktreeIsolation) { const wtPath = worktreeIsolation.path; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ov = agentConfig as any; + ov.targetDir = wtPath; + ov.cwd = wtPath; ov.getTargetDir = () => wtPath; ov.getCwd = () => wtPath; ov.getWorkingDir = () => wtPath; + ov.getProjectRoot = () => wtPath; + const wtFileService = new FileDiscoveryService(wtPath); + ov.fileDiscoveryService = wtFileService; + ov.getFileService = () => wtFileService; + const wtWorkspace = new WorkspaceContext(wtPath); + ov.workspaceContext = wtWorkspace; + ov.getWorkspaceContext = () => wtWorkspace; } // Create the subagent. Fork bypasses SubagentManager because its @@ -1302,77 +1443,9 @@ class AgentToolInvocation extends BaseToolInvocation { taskPrompt = `${notice}\n\n${taskPrompt}`; } - // Cleanup helper invoked after the subagent terminates (success, - // failure, or cancel). - // - // Two independent reasons to preserve the worktree: - // 1. The working tree is not clean (uncommitted edits). - // 2. The branch carries commits not reachable from any other - // ref — `git status` would call this "clean", but dropping - // the worktree+branch would silently destroy the commits. - // - // Removing the worktree always uses `git branch -d` (safe delete); - // if git refuses because of unmerged commits, `branchPreserved` - // is surfaced so the caller can tell the user. - const cleanupWorktreeIsolation = async (): Promise<{ - preservedPath?: string; - preservedBranch?: string; - }> => { - if (!worktreeIsolation) return {}; - const wtService = new GitWorktreeService(this.config.getTargetDir()); - const isolation = worktreeIsolation; - let hasChanges = true; - try { - hasChanges = await wtService.hasWorktreeChanges(isolation.path); - } catch { - // Fail-closed: assume changes exist. - } - let hasUnmerged = true; - try { - hasUnmerged = await wtService.hasUnmergedWorktreeCommits( - isolation.slug, - ); - } catch { - // Fail-closed: assume there is uncovered work. - } - if (hasChanges || hasUnmerged) { - return { - preservedPath: isolation.path, - preservedBranch: isolation.branch, - }; - } - try { - const result = await wtService.removeUserWorktree(isolation.slug, { - deleteBranch: true, - }); - if (result.branchPreserved) { - // The status check said "clean" and the ref check said - // "fully covered", but the safe-delete still refused — - // a race or an oddly-shaped ref. Be loud rather than - // silently force-deleting. - return { - preservedPath: isolation.path, - preservedBranch: isolation.branch, - }; - } - } catch (error) { - debugLogger.warn( - `[Agent] Failed to remove ephemeral worktree ${isolation.path}: ${error}`, - ); - } - return {}; - }; - - const formatWorktreeSuffix = (info: { - preservedPath?: string; - preservedBranch?: string; - }): string => { - if (!info.preservedPath) return ''; - return ( - `\n\n[worktree preserved: ${info.preservedPath} ` + - `(branch ${info.preservedBranch ?? 'unknown'})]` - ); - }; + // (cleanupWorktreeIsolation and formatWorktreeSuffix are hoisted + // to execute()'s outer scope — see the top of this method — so + // the outer catch can also invoke them.) const contextState = new ContextState(); contextState.set('task_prompt', taskPrompt); @@ -1659,18 +1732,26 @@ class AgentToolInvocation extends BaseToolInvocation { }); } } catch (error) { - const errorMsg = + const baseErrorMsg = error instanceof Error ? error.message : String(error); - debugLogger.error(`[Agent] Background agent failed: ${errorMsg}`); + debugLogger.error( + `[Agent] Background agent failed: ${baseErrorMsg}`, + ); - // Best-effort: preserve or remove the isolation worktree before - // the registry record reflects the failure. + // Preserve or remove the isolation worktree, AND surface the + // preserved path/branch in the registry message. Without + // this, an agent that crashed mid-edit would have its + // worktree preserved on disk but the user would never see + // its location in the failure notification — they would + // assume nothing was left behind. + let wtSuffix = ''; try { - await cleanupWorktreeIsolation(); + wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); } catch { - // Already logged inside the helper; do not mask the original - // failure mode. + // Helper logs its own failures; don't mask the original + // crash message. } + const errorMsg = baseErrorMsg + wtSuffix; // If the error came from a cancellation, preserve the cancelled // status so the model's notification matches what task_stop @@ -1954,6 +2035,24 @@ class AgentToolInvocation extends BaseToolInvocation { error instanceof Error ? error.message : String(error); debugLogger.error(`[AgentTool] Error running subagent: ${errorMessage}`); + // Final fallback for the isolation worktree: if the failure + // happened between provisioning and the inner try (e.g. inside + // `createApprovalModeOverride`, the agent constructor, or + // anywhere else upstream of the foreground/background try blocks + // that own cleanup), the worktree is still on disk. Reap or + // preserve it here, and surface the preserved path/branch in the + // failure message so the user can recover it. + let wtSuffix = ''; + if (worktreeIsolation) { + try { + wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); + } catch (cleanupError) { + debugLogger.warn( + `[AgentTool] Worktree cleanup after error failed: ${cleanupError}`, + ); + } + } + const errorDisplay: AgentResultDisplay = { ...this.currentDisplay!, status: 'failed', @@ -1961,7 +2060,7 @@ class AgentToolInvocation extends BaseToolInvocation { }; return { - llmContent: `Failed to run subagent: ${errorMessage}`, + llmContent: `Failed to run subagent: ${errorMessage}${wtSuffix}`, returnDisplay: errorDisplay, }; } diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index 5f5bbabc66d..369eeb371ce 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -66,13 +66,25 @@ describe('GitWorktreeService.validateUserWorktreeSlug', () => { }); describe('GitWorktreeService.generateAutoSlug', () => { - it('produces a slug matching the {adj}-{noun}-{4hex} pattern', () => { - for (let i = 0; i < 10; i++) { + it('produces a slug matching the {adj}-{noun}-{6hex} pattern', () => { + for (let i = 0; i < 50; i++) { const slug = GitWorktreeService.generateAutoSlug(); - expect(slug).toMatch(/^[a-z]+-[a-z]+-[0-9a-f]{4}$/); + expect(slug).toMatch(/^[a-z]+-[a-z]+-[0-9a-f]{6}$/); expect(GitWorktreeService.validateUserWorktreeSlug(slug)).toBeNull(); } }); + + it('uses a strong RNG so 100 consecutive slugs are unique', () => { + // Math.random in the prior implementation had a 1/65k chance per + // suffix; with 100 slugs that was ~7% chance of a collision in + // tests. The randomBytes-backed 6-hex suffix should be essentially + // collision-free at this sample size. + const seen = new Set(); + for (let i = 0; i < 100; i++) { + seen.add(GitWorktreeService.generateAutoSlug()); + } + expect(seen.size).toBe(100); + }); }); describe('GitWorktreeService.getUserWorktreesDir / getUserWorktreePath', () => { diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts index ea9448b374d..e8cf6a3ba61 100644 --- a/packages/core/src/tools/enter-worktree.ts +++ b/packages/core/src/tools/enter-worktree.ts @@ -64,21 +64,36 @@ class EnterWorktreeInvocation extends BaseToolInvocation< } async execute(_signal: AbortSignal): Promise { - const projectRoot = this.config.getTargetDir(); - const service = new GitWorktreeService(projectRoot); + const cwd = this.config.getTargetDir(); - const gitCheck = await service.checkGitAvailable(); + // First-pass service rooted at cwd, only to find the repo top-level. + // We can't use cwd as the worktree anchor because launching from a + // monorepo subdirectory would scatter `.qwen/worktrees/` under each + // package's directory, and the startup sweep at `Config.initialize` + // would never find them. + const probe = new GitWorktreeService(cwd); + + const gitCheck = await probe.checkGitAvailable(); if (!gitCheck.available) { - return errorResult(gitCheck.error ?? 'Git is not available.'); + const reason = gitCheck.error ?? 'Git is not available.'; + debugLogger.warn(`enter_worktree: ${reason}`); + return errorResult(reason); } - const isRepo = await service.isGitRepository(); + const isRepo = await probe.isGitRepository(); if (!isRepo) { - return errorResult( - `Cannot create a worktree: ${projectRoot} is not a git repository. Initialize the repo with \`git init\` first.`, - ); + const reason = `Cannot create a worktree: ${cwd} is not a git repository. Initialize the repo with \`git init\` first.`; + debugLogger.warn(`enter_worktree: ${reason}`); + return errorResult(reason); } + // Resolve to the repo's top-level so worktrees always live under + // `/.qwen/worktrees/`, regardless of which subdirectory + // the user invoked the tool from. + const projectRoot = (await probe.getRepoTopLevel()) ?? cwd; + const service = + projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); + // Treat an empty `name` ('') the same as undefined — some models pass // `{ name: '' }` when the schema marks `name` as optional, expecting // the auto-generated slug. Without this, validation would reject the @@ -90,12 +105,15 @@ class EnterWorktreeInvocation extends BaseToolInvocation< const slug = requested ?? GitWorktreeService.generateAutoSlug(); const validation = GitWorktreeService.validateUserWorktreeSlug(slug); if (validation) { + debugLogger.warn(`enter_worktree: invalid slug ${slug}: ${validation}`); return errorResult(validation); } const result = await service.createUserWorktree(slug); if (!result.success || !result.worktree) { - return errorResult(result.error ?? 'Failed to create worktree.'); + const reason = result.error ?? 'Failed to create worktree.'; + debugLogger.warn(`enter_worktree: createUserWorktree failed: ${reason}`); + return errorResult(reason); } const output: EnterWorktreeOutput = { diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index f17af414d7d..4579bd65b84 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -130,9 +130,15 @@ class ExitWorktreeInvocation extends BaseToolInvocation< if (!this.params.discard_changes) { const counts = await service.countWorktreeChanges(worktreePath); if (counts === null) { + // Inspecting the worktree itself failed — most likely a corrupt + // git index, a permission problem, or the worktree dir was + // mutated under us. Refuse rather than suggesting + // `discard_changes: true`, which would tell the user to bypass + // a safety check whose precondition is unknown. The user should + // diagnose the underlying repo problem first. return errorResult( - `Cannot inspect worktree "${this.params.name}" to verify it has no changes. ` + - `Pass \`discard_changes: true\` to remove anyway.`, + `Cannot inspect worktree "${this.params.name}" — git status failed against ${worktreePath}. ` + + `Check filesystem permissions and repository integrity, then call ${ToolNames.EXIT_WORKTREE} again.`, ); } const total = counts.tracked + counts.untracked; From ad7639b004d4e46f708eef603636f2887417e3bc Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 10:35:59 +0800 Subject: [PATCH 06/15] fix(worktree): use randomInt to silence CodeQL biased-modulo finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL's `js/biased-cryptographic-random` flagged `randomBytes(4)[i] % ARRAY.length` in `generateAutoSlug`. The math is actually exact for the current word-list lengths (256 % 8 == 0), but the lint rule does not know that — and a future contributor changing the list to a non-power-of-two length would silently introduce bias. Switched the index lookups to `crypto.randomInt(0, length)`, which uses rejection sampling and is uniform by construction. Suffix still uses `randomBytes(3).toString('hex')` since hex encoding is unbiased. Co-Authored-By: Claude Opus 4.7 --- .../core/src/services/gitWorktreeService.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index cf36b6a1101..5ba158f77da 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomInt } from 'node:crypto'; import { execSync } from 'node:child_process'; import { simpleGit, CheckRepoActions } from 'simple-git'; import type { SimpleGit } from 'simple-git'; @@ -875,11 +875,13 @@ export class GitWorktreeService { /** * Generates an auto-slug `{adj}-{noun}-{6hex}` for an unnamed worktree. * - * Uses `randomBytes` (the same source as agent slugs) so the two - * RNG paths in this file stay consistent and so the suffix is genuinely - * unpredictable rather than `Math.random()`-derived. The 6-hex suffix - * yields ~16M combinations × 8 adj × 8 noun ≈ 1B distinct slugs, - * eliminating the practical collision concern called out in review. + * Uses `randomInt` for the word-list indices (uniform by construction + * via rejection sampling — `randomBytes[i] % len` would be biased + * whenever `len` doesn't divide `2^8`, and CodeQL's + * `js/biased-cryptographic-random` rule flags it even when it + * happens to be exact). Uses `randomBytes` for the suffix because + * hex encoding of raw bytes is unbiased. ~16M combinations × 8 adj + * × 8 noun ≈ 1B distinct slugs. */ static generateAutoSlug(): string { const ADJECTIVES = [ @@ -893,10 +895,9 @@ export class GitWorktreeService { 'quick', ]; const NOUNS = ['fox', 'owl', 'elm', 'oak', 'ray', 'sky', 'leaf', 'pine']; - const bytes = randomBytes(4); - const adj = ADJECTIVES[bytes[0] % ADJECTIVES.length]; - const noun = NOUNS[bytes[1] % NOUNS.length]; - const suffix = bytes.toString('hex').slice(0, 6); + const adj = ADJECTIVES[randomInt(0, ADJECTIVES.length)]; + const noun = NOUNS[randomInt(0, NOUNS.length)]; + const suffix = randomBytes(3).toString('hex'); return `${adj}-${noun}-${suffix}`; } From ae5aa557eab08c4411acd89051c23c25b4291d70 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 14:01:44 +0800 Subject: [PATCH 07/15] fix(worktree): address review round 3 findings 1-6 from PR #4073 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round added `getRepoTopLevel` for `enter_worktree`'s provisioning, but missed three sibling call sites that still used the raw cwd. The double-cleanup race in the foreground path also leaked stale `[worktree preserved]` suffixes on rejected promises. All six findings from the deeper audit are addressed: 1. exit_worktree now resolves through `getRepoTopLevel()` before building its `GitWorktreeService`, mirroring `enter_worktree`. Without this, launching `qwen` from a monorepo subdirectory created the worktree under the repo root but exit_worktree looked under the subdir's `.qwen/worktrees/` and always returned "Worktree not found". Verified end-to-end: enter + exit from `packages/core/` works. 2. agent.ts cleanup helper now nulls `worktreeIsolation` immediately after capturing the closure value. The previous structure could reach the helper twice — once in the foreground try's success path and once in the foreground finally fallback (or once in the inner try and once in the outer catch on a thrown rejection). The second call would `hasWorktreeChanges()` against a directory the first call already removed, fail-closed, and emit a bogus `[worktree preserved: ]` suffix. 3. Config.initialize's startup sweep now resolves `getRepoTopLevel()` before invoking `cleanupStaleAgentWorktrees`. Without this, every subdir launch scanned a non-existent `/.qwen/worktrees/` and the 30-day expiry sweep was permanently a no-op. 4. agent.ts's `buildWorktreeNotice` now passes `worktreeIsolation.repoRoot` as `parentCwd` instead of `this.config.getTargetDir()`. The notice's path-translation guidance (≈ "translate paths from to ") would otherwise misdirect the subagent in a monorepo subdir launch. 5. Removed dead method `GitWorktreeService.listUserWorktrees`. It had no callers anywhere in the codebase and used `execSync` in a loop (would have blocked the event loop if anyone wired it up). 6. `localBranchExists` no longer swallows git failures silently. The defensive `false` default is preserved (so `git worktree add -b` itself surfaces the conflict if the check missed an existing branch), but the catch now logs via `debugLogger.warn` so disk-full / permission / ref-store-corruption cases are visible in debug output instead of being invisible. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 27 +++++++-- .../core/src/services/gitWorktreeService.ts | 55 +++---------------- packages/core/src/tools/agent/agent.ts | 16 +++++- packages/core/src/tools/exit-worktree.ts | 13 ++++- 4 files changed, 54 insertions(+), 57 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e2495fa29b6..4fb2eae13ec 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1245,16 +1245,31 @@ export class Config { // running it on every startup cannot destroy user work. We do not // await this: it is a hygiene task that must never delay the // first model turn. + // + // Anchor the sweep at the repo top-level so it scans the same + // directory the worktree creators (`enter_worktree` and + // `agent isolation:'worktree'`) write to. Using `this.targetDir` + // directly would cause launches from a monorepo subdirectory to + // scan `/.qwen/worktrees/` — which never exists — and the + // sweep would silently be a no-op forever. if (!this.getBareMode()) { - void import('../services/worktreeCleanup.js') - .then(({ cleanupStaleAgentWorktrees }) => - cleanupStaleAgentWorktrees(this.targetDir), - ) - .catch((error: unknown) => { + void (async () => { + try { + const { GitWorktreeService } = await import( + '../services/gitWorktreeService.js' + ); + const probe = new GitWorktreeService(this.targetDir); + const root = (await probe.getRepoTopLevel()) ?? this.targetDir; + const { cleanupStaleAgentWorktrees } = await import( + '../services/worktreeCleanup.js' + ); + await cleanupStaleAgentWorktrees(root); + } catch (error: unknown) { this.debugLogger.debug( `Stale worktree sweep failed (non-fatal): ${error}`, ); - }); + } + })(); } } diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 5ba158f77da..98abb95da9d 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -1030,7 +1030,13 @@ export class GitWorktreeService { `refs/heads/${branchName}`, ]); return out.trim().length > 0; - } catch { + } catch (error) { + // Defensive default: if we cannot tell, assume the branch is + // absent so the create attempt fires. Worst case `git worktree + // add -b` itself errors out on the duplicate. But log so the + // root cause (disk full, permission, ref-store corruption) shows + // up in debug output instead of being invisible. + debugLogger.warn(`localBranchExists failed for ${branchName}: ${error}`); return false; } } @@ -1172,53 +1178,6 @@ export class GitWorktreeService { } } - /** - * Lists all user worktrees in this repo by reading the worktrees directory. - */ - async listUserWorktrees(): Promise { - const worktreesDir = this.getUserWorktreesDir(); - try { - const entries = await fs.readdir(worktreesDir, { withFileTypes: true }); - const result: WorktreeInfo[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith('.')) continue; - const worktreePath = path.join(worktreesDir, entry.name); - let branch = ''; - try { - branch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - } catch { - // Ignore: worktree may be in an inconsistent state - } - let createdAt = Date.now(); - try { - const stats = await fs.stat(worktreePath); - createdAt = stats.birthtimeMs || stats.mtimeMs; - } catch { - // Ignore stat errors - } - result.push({ - id: entry.name, - name: entry.name, - path: worktreePath, - branch, - isActive: true, - createdAt, - }); - } - return result; - } catch (error) { - if (isNodeError(error) && error.code === 'ENOENT') { - return []; - } - throw error; - } - } - /** * Reports whether a worktree has uncommitted tracked changes (staged or * unstaged) or untracked files. Used by `ExitWorktreeTool` to refuse diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 28d6d0b53eb..f2a0da46f3b 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1133,6 +1133,14 @@ class AgentToolInvocation extends BaseToolInvocation { }> => { if (!worktreeIsolation) return {}; const isolation = worktreeIsolation; + // Null the closure var BEFORE doing any work so any concurrent + // re-entry (e.g. the foreground-finally fallback firing in + // parallel with the outer catch on a thrown rejection) sees no + // isolation and bails. Without this, the second caller would + // operate on a worktree directory the first caller has already + // removed and `hasWorktreeChanges()` would fail-closed and + // produce a bogus `[worktree preserved: ]` suffix. + worktreeIsolation = null; const wtService = new GitWorktreeService(isolation.repoRoot); let hasChanges = true; try { @@ -1435,9 +1443,15 @@ class AgentToolInvocation extends BaseToolInvocation { // operating in an isolated worktree. The mechanical isolation // above guarantees correctness; the notice reduces user-visible // surprises when the model summarises file paths. + // + // The notice's "parent cwd" must be the repo top-level (where the + // user's main work lives), not whatever subdirectory the user + // happened to launch `qwen` from — otherwise the path-translation + // guidance would tell the agent to map worktree paths back to a + // monorepo subdir that may not contain the parent file at all. if (worktreeIsolation) { const notice = buildWorktreeNotice( - this.config.getTargetDir(), + worktreeIsolation.repoRoot, worktreeIsolation.path, ); taskPrompt = `${notice}\n\n${taskPrompt}`; diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index 4579bd65b84..1e1145f85ab 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -82,8 +82,17 @@ class ExitWorktreeInvocation extends BaseToolInvocation< } async execute(_signal: AbortSignal): Promise { - const projectRoot = this.config.getTargetDir(); - const service = new GitWorktreeService(projectRoot); + // Mirror `enter_worktree`: anchor at the repo top-level so we look + // for the worktree under the same directory it was created in. + // Otherwise launching `qwen` from a subdirectory of a monorepo would + // make exit_worktree look at `/.qwen/worktrees/`, + // which never exists, and every call would return "Worktree not + // found" even when the worktree is alive. + const cwd = this.config.getTargetDir(); + const probe = new GitWorktreeService(cwd); + const projectRoot = (await probe.getRepoTopLevel()) ?? cwd; + const service = + projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); const worktreePath = service.getUserWorktreePath(this.params.name); const branch = `worktree-${this.params.name}`; From 3e2002588becc2d14e01cba0a49548b74c78e340 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 16:08:31 +0800 Subject: [PATCH 08/15] fix(worktree): address review round 4 findings (data-loss + visibility) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven actionable findings from a deeper audit, all closed: 1. User worktree slugs could collide with ephemeral-agent shape `validateUserWorktreeSlug` did not reject names starting with `agent-`, so a user-named `agent-1234567` matched the cleanup regex `/^agent-[0-9a-f]{7}$/` and would be silently swept after 30 days along with whatever work was in it. Now reserved — clear error message points users at the cause. 2. Slug producer and consumer were string-coupled across files `agent.ts` hardcoded `agent-${hex(7)}` and `worktreeCleanup.ts` independently hardcoded `/^agent-[0-9a-f]{7}$/`. Future change to hex length on one side would silently break the other. Lifted `AGENT_WORKTREE_PREFIX`, `AGENT_WORKTREE_HEX_LENGTH`, `AGENT_WORKTREE_SLUG_PATTERN`, and `generateAgentWorktreeSlug()` to `gitWorktreeService.ts`; both call sites import them. 3. Startup sweep was invisible at default log level Fire-and-forget sweep used `debug` for errors and discarded the success count. A leak-chasing operator had no log breadcrumb. Errors promoted to `warn`; successful removals (count > 0) logged at `info`. 4. `getRepoTopLevel()` silent catch Returned `null` on any git failure with no log. Combined with `?? cwd` fallback in callers, a flaky git would have made worktree creators and the startup sweep disagree silently about which dir to use. Now logs the underlying error. 5. `hasTrackedChanges()` silent catch Cleanup's fail-closed `return true` had no log. Couldn't tell "has real changes — leave alone" from "git index unreadable — repo may be corrupt". Now logs. 6. `cleanupWorktreeIsolation` claimed `preservedPath` for a removed dir When `removeUserWorktree` returns `{ success: true, branchPreserved: true }` it has already deleted the directory and failed only on `git branch -d`. The helper still reported the (now non-existent) path as preserved. Now returns only `preservedBranch` for that case; `formatWorktreeSuffix` emits a distinct message instructing recovery via `git worktree add `. 7. `removeUserWorktree` swallowed branch-delete failures Both `-d` and `-D` catch blocks were empty. Locked refs, perms, disk full all looked identical to "unmerged commits". Both now `debugLogger.warn` with the underlying error. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 17 ++++- .../core/src/services/gitWorktreeService.ts | 72 +++++++++++++++++-- packages/core/src/services/worktreeCleanup.ts | 23 ++++-- packages/core/src/tools/agent/agent.ts | 43 ++++++++--- .../core/src/tools/enter-worktree.test.ts | 26 +++++++ 5 files changed, 159 insertions(+), 22 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4fb2eae13ec..62005fdbc9f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1263,9 +1263,22 @@ export class Config { const { cleanupStaleAgentWorktrees } = await import( '../services/worktreeCleanup.js' ); - await cleanupStaleAgentWorktrees(root); + const removed = await cleanupStaleAgentWorktrees(root); + if (removed > 0) { + // Promote to `info` so operators chasing a worktree leak in + // production can grep the logs and see the sweep actually + // ran. The cleanup helper itself uses `debug`-level + // per-entry logs, which are silent at default verbosity. + this.debugLogger.info( + `Stale worktree sweep removed ${removed} ephemeral worktree(s) under ${root}`, + ); + } } catch (error: unknown) { - this.debugLogger.debug( + // Promote sweep errors to `warn` for the same reason: a + // permission failure / disk full / repo-corruption case + // should leave a visible breadcrumb instead of being + // invisible at the default log level. + this.debugLogger.warn( `Stale worktree sweep failed (non-fatal): ${error}`, ); } diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 98abb95da9d..64153e4f668 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -33,6 +33,37 @@ export const BASELINE_COMMIT_MESSAGE = 'baseline (dirty state overlay)'; */ export const WORKTREES_DIR = 'worktrees'; +// ────────────────────────────────────────────────────────────────────── +// Ephemeral agent-worktree slug format. Shared between the producer +// (`AgentTool isolation: 'worktree'`), the consumer +// (`cleanupStaleAgentWorktrees`) and the validator +// (`validateUserWorktreeSlug` reserves the prefix). Changing any of +// these constants must be done in one place so a regex / generator +// mismatch can never silently leak or destroy work. +// ────────────────────────────────────────────────────────────────────── + +/** Slug prefix used for worktrees created by `AgentTool isolation:'worktree'`. */ +export const AGENT_WORKTREE_PREFIX = 'agent'; + +/** Number of random hex characters appended after the prefix. */ +export const AGENT_WORKTREE_HEX_LENGTH = 7; + +/** Regex that matches the exact ephemeral-agent slug shape. */ +export const AGENT_WORKTREE_SLUG_PATTERN = new RegExp( + `^${AGENT_WORKTREE_PREFIX}-[0-9a-f]{${AGENT_WORKTREE_HEX_LENGTH}}$`, +); + +/** + * Generates a fresh ephemeral-agent slug. Centralised so the format + * stays in lock-step with {@link AGENT_WORKTREE_SLUG_PATTERN}. + */ +export function generateAgentWorktreeSlug(): string { + const hex = randomBytes(Math.ceil(AGENT_WORKTREE_HEX_LENGTH / 2)) + .toString('hex') + .slice(0, AGENT_WORKTREE_HEX_LENGTH); + return `${AGENT_WORKTREE_PREFIX}-${hex}`; +} + export interface WorktreeInfo { /** Unique identifier for this worktree */ id: string; @@ -172,7 +203,14 @@ export class GitWorktreeService { const out = await this.git.revparse(['--show-toplevel']); const top = out.trim(); return top.length > 0 ? top : null; - } catch { + } catch (error) { + // Caller falls back to its cwd via `?? cwd`. Log so a corrupt + // repo / permission failure leaves a trail — otherwise the + // worktree creator and startup sweep can disagree silently about + // where worktrees live, and the sweep would never find them. + debugLogger.warn( + `getRepoTopLevel failed at ${this.sourceRepoPath}: ${error}`, + ); return null; } } @@ -908,6 +946,12 @@ export class GitWorktreeService { * - Non-empty, ≤ 64 chars * - Only `[a-zA-Z0-9._-]` characters; no path separators * - No `..` or leading/trailing dots (would resolve outside the worktrees dir) + * - Must not start with `agent-`: that prefix is reserved for the + * ephemeral worktrees `AgentTool isolation:'worktree'` produces. + * The startup sweep auto-removes anything matching + * {@link AGENT_WORKTREE_SLUG_PATTERN}, so a user-named + * `agent-1234567` would be silently deleted after 30 days along + * with any work it contained. */ static validateUserWorktreeSlug(slug: string): string | null { if (typeof slug !== 'string' || slug.length === 0) { @@ -922,6 +966,13 @@ export class GitWorktreeService { if (slug.includes('..') || slug.startsWith('.') || slug.startsWith('-')) { return 'Worktree name must not start with "." or "-" or contain "..".'; } + if (slug.startsWith(`${AGENT_WORKTREE_PREFIX}-`)) { + return ( + `Worktree name must not start with "${AGENT_WORKTREE_PREFIX}-": that prefix ` + + `is reserved for ephemeral agent worktrees and is subject to ` + + `automatic cleanup after 30 days.` + ); + } return null; } @@ -1113,17 +1164,28 @@ export class GitWorktreeService { try { await this.git.branch(['-d', branchName]); return { success: true }; - } catch { - // Fall through to forced delete or preservation. + } catch (error) { + // Refused either because the branch carries unmerged commits + // (the common case, handled below by surfacing `branchPreserved`) + // or because of a real failure (locked ref, permissions, disk + // full). Log so the caller's "branch preserved" message can be + // cross-referenced with a concrete reason. + debugLogger.warn( + `removeUserWorktree: safe branch delete failed for ${branchName}: ${error}`, + ); } if (options.forceDeleteBranch) { try { await this.git.branch(['-D', branchName]); return { success: true }; - } catch { + } catch (error) { // Best-effort: branch may have been deleted already, or may not - // exist (a no-op). + // exist (a no-op). Still log because a true filesystem error + // would otherwise be invisible. + debugLogger.warn( + `removeUserWorktree: force branch delete failed for ${branchName}: ${error}`, + ); } } diff --git a/packages/core/src/services/worktreeCleanup.ts b/packages/core/src/services/worktreeCleanup.ts index b25b46d2f23..ed47473c1a6 100644 --- a/packages/core/src/services/worktreeCleanup.ts +++ b/packages/core/src/services/worktreeCleanup.ts @@ -7,7 +7,10 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { simpleGit } from 'simple-git'; -import { GitWorktreeService } from './gitWorktreeService.js'; +import { + AGENT_WORKTREE_SLUG_PATTERN, + GitWorktreeService, +} from './gitWorktreeService.js'; import { createDebugLogger } from '../utils/debugLogger.js'; // Branch name produced by `GitWorktreeService.createUserWorktree`: the @@ -23,12 +26,16 @@ const debugLogger = createDebugLogger('WORKTREE_CLEANUP'); * Currently only the `agent-<7hex>` shape produced by * `AgentTool isolation:'worktree'` qualifies. User-named worktrees created * via `EnterWorktreeTool` are NEVER swept — they are managed manually via - * `ExitWorktreeTool` and would surprise the user if removed under them. + * `ExitWorktreeTool`, and `validateUserWorktreeSlug` reserves the + * `agent-` prefix so a user-named slug can never accidentally match + * here. * * Mirrors claude-code's `EPHEMERAL_WORKTREE_PATTERNS` in * `utils/worktree.ts`, restricted to the patterns qwen-code actually emits. */ -const EPHEMERAL_WORKTREE_PATTERNS: readonly RegExp[] = [/^agent-[0-9a-f]{7}$/]; +const EPHEMERAL_WORKTREE_PATTERNS: readonly RegExp[] = [ + AGENT_WORKTREE_SLUG_PATTERN, +]; /** * Default age threshold for stale ephemeral worktree cleanup (30 days). @@ -151,7 +158,15 @@ async function hasTrackedChanges(worktreePath: string): Promise { status.renamed.length > 0 || status.created.length > 0 ); - } catch { + } catch (error) { + // Fail-closed (assume dirty so the worktree is preserved) but log + // so the operator can distinguish "has real changes — leave alone" + // from "git index is unreadable — repo may be corrupt". Without + // this, a permission error or unmounted filesystem would silently + // keep the worktree forever with no breadcrumb. + debugLogger.warn( + `hasTrackedChanges: cannot inspect ${worktreePath} — assuming dirty: ${error}`, + ); return true; } } diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index f2a0da46f3b..1ad35047805 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -42,8 +42,10 @@ import { isInForkExecution, runInForkContext, } from './fork-subagent.js'; -import { randomBytes } from 'node:crypto'; -import { GitWorktreeService } from '../../services/gitWorktreeService.js'; +import { + generateAgentWorktreeSlug, + GitWorktreeService, +} from '../../services/gitWorktreeService.js'; import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; import { WorkspaceContext } from '../../utils/workspaceContext.js'; import { @@ -1191,12 +1193,18 @@ class AgentToolInvocation extends BaseToolInvocation { // covered", but the safe-delete still refused — most likely a // race where commits landed between the checks and the delete. // Be loud rather than silently force-deleting. + // + // Critical: do NOT return `preservedPath` here. The worktree + // *directory* is already gone (removeUserWorktree removes the + // dir before attempting `git branch -d`). The branch alone is + // what's preserved. Reporting the old path as preserved would + // tell the parent model / user the worktree is recoverable at + // a location that no longer exists. debugLogger.warn( - `[Agent] Removed worktree ${isolation.path} but kept branch ` + - `${isolation.branch} (unmerged commits at delete time)`, + `[Agent] Removed worktree directory ${isolation.path} but kept ` + + `branch ${isolation.branch} (unmerged commits at delete time)`, ); return { - preservedPath: isolation.path, preservedBranch: isolation.branch, }; } @@ -1216,11 +1224,24 @@ class AgentToolInvocation extends BaseToolInvocation { preservedPath?: string; preservedBranch?: string; }): string => { - if (!info.preservedPath) return ''; - return ( - `\n\n[worktree preserved: ${info.preservedPath} ` + - `(branch ${info.preservedBranch ?? 'unknown'})]` - ); + if (info.preservedPath) { + return ( + `\n\n[worktree preserved: ${info.preservedPath} ` + + `(branch ${info.preservedBranch ?? 'unknown'})]` + ); + } + if (info.preservedBranch) { + // Worktree directory was removed but the branch was kept (race: + // unmerged commits landed after the pre-checks passed). Tell + // the user which branch holds the work so they can recover via + // `git worktree add ` or by force-deleting + // it if they really meant to discard. + return ( + `\n\n[worktree directory removed; branch ${info.preservedBranch} ` + + `preserved — recover with \`git worktree add ${info.preservedBranch}\`]` + ); + } + return ''; }; try { @@ -1324,7 +1345,7 @@ class AgentToolInvocation extends BaseToolInvocation { const projectRoot = (await probe.getRepoTopLevel()) ?? cwd; const wtService = projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); - const slug = `agent-${randomBytes(4).toString('hex').slice(0, 7)}`; + const slug = generateAgentWorktreeSlug(); const created = await wtService.createUserWorktree(slug); if (!created.success || !created.worktree) { return failWorktreeProvisioning( diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index 369eeb371ce..f8847fd3489 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -63,6 +63,32 @@ describe('GitWorktreeService.validateUserWorktreeSlug', () => { GitWorktreeService.validateUserWorktreeSlug('a'.repeat(64)), ).toBeNull(); }); + + it('reserves the `agent-` prefix for ephemeral agent worktrees', () => { + // A user-named `agent-1234567` would match + // AGENT_WORKTREE_SLUG_PATTERN and be silently swept after 30 days. + expect( + GitWorktreeService.validateUserWorktreeSlug('agent-1234567'), + ).toMatch(/reserved/i); + expect( + GitWorktreeService.validateUserWorktreeSlug('agent-feature'), + ).toMatch(/reserved/i); + // The standalone word "agent" or a different prefix is fine. + expect(GitWorktreeService.validateUserWorktreeSlug('agent')).toBeNull(); + expect(GitWorktreeService.validateUserWorktreeSlug('agentic')).toBeNull(); + expect(GitWorktreeService.validateUserWorktreeSlug('my-agent')).toBeNull(); + }); +}); + +describe('generateAgentWorktreeSlug', () => { + it('produces slugs that match AGENT_WORKTREE_SLUG_PATTERN', async () => { + const { generateAgentWorktreeSlug, AGENT_WORKTREE_SLUG_PATTERN } = + await import('../services/gitWorktreeService.js'); + for (let i = 0; i < 50; i++) { + const slug = generateAgentWorktreeSlug(); + expect(slug).toMatch(AGENT_WORKTREE_SLUG_PATTERN); + } + }); }); describe('GitWorktreeService.generateAutoSlug', () => { From fbf12c6ed7cc482cf6b535eabacc1f6e8e344e43 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 16:53:11 +0800 Subject: [PATCH 09/15] =?UTF-8?q?refactor(worktree):=20self-review=20pass?= =?UTF-8?q?=20=E2=80=94=20reuse,=20parallelism,=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review caught a handful of issues across three categories: Reuse: - `pathExists` in the new code now uses the existing `fileExists` from `utils/fileUtils.ts` instead of duplicating an `fs.access` wrapper. - `worktree-` branch prefix was string-literalled in five places. Added `WORKTREE_BRANCH_PREFIX` and `worktreeBranchForSlug(slug)` exports in `gitWorktreeService.ts`; updated `gitWorktreeService.ts`, `worktreeCleanup.ts`, and `exit-worktree.ts` to use them. Future prefix changes are a single edit. Efficiency: - `Config.initialize` used two `await import(...)` calls inside the startup-sweep IIFE, paying that cost on every CLI start. Switched to static imports at the top of `config.ts` — the modules are tiny and the dynamic indirection bought nothing. - `cleanupWorktreeIsolation` in `agent.ts` ran `hasWorktreeChanges` and `hasUnmergedWorktreeCommits` sequentially. They have no data dependency on each other and each spawns its own `git` invocation; `Promise.all` halves the cleanup wall-clock on the common path. Same fix in `worktreeCleanup.ts`'s per-entry loop. - `ensureWorktreesGitignored` used `fs.access` then `fs.writeFile`, a TOCTOU race when two agent invocations created worktrees concurrently (both could pass the `access` check and the second would clobber the first's `.gitignore`). Now writes with `flag: 'wx'` and treats `EEXIST` as the no-op case — atomic in one syscall. Quality: - Dropped the `worktreeCleanupRan` boolean in the foreground execution path. `cleanupWorktreeIsolation` already nulls its closure variable at the top of every call (see the comment at its definition), so re-entries are no-ops. The boolean and its tracking were dead weight that obscured the real guard. - Trimmed the Phase-2 override comment block to drop the WHAT-stating enumerations (items 3 and 4 just narrated the lines below) and removed a navigation comment about hoisted helpers — the helpers are visible at the top of the same method. 84 unit tests pass; typecheck clean. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 8 +- .../core/src/services/gitWorktreeService.ts | 46 +++++--- packages/core/src/services/worktreeCleanup.ts | 17 +-- packages/core/src/tools/agent/agent.ts | 107 ++++++------------ packages/core/src/tools/exit-worktree.ts | 7 +- 5 files changed, 81 insertions(+), 104 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 62005fdbc9f..a45d5c63723 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -43,6 +43,8 @@ import { type FileEncodingType, } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; +import { GitWorktreeService } from '../services/gitWorktreeService.js'; +import { cleanupStaleAgentWorktrees } from '../services/worktreeCleanup.js'; import { CronScheduler } from '../services/cronScheduler.js'; // Tools — only lightweight imports; tool classes are lazy-loaded via dynamic import @@ -1255,14 +1257,8 @@ export class Config { if (!this.getBareMode()) { void (async () => { try { - const { GitWorktreeService } = await import( - '../services/gitWorktreeService.js' - ); const probe = new GitWorktreeService(this.targetDir); const root = (await probe.getRepoTopLevel()) ?? this.targetDir; - const { cleanupStaleAgentWorktrees } = await import( - '../services/worktreeCleanup.js' - ); const removed = await cleanupStaleAgentWorktrees(root); if (removed > 0) { // Promote to `info` so operators chasing a worktree leak in diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 64153e4f668..b5ef92f781a 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -14,10 +14,19 @@ import { Storage } from '../config/storage.js'; import { isCommandAvailable } from '../utils/shell-utils.js'; import { isNodeError } from '../utils/errors.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { fileExists } from '../utils/fileUtils.js'; import { initRepositoryWithMainBranch } from './gitInit.js'; const debugLogger = createDebugLogger('GIT_WORKTREE_SERVICE'); +/** Prefix applied to every general-purpose worktree branch. */ +export const WORKTREE_BRANCH_PREFIX = 'worktree-'; + +/** Returns the canonical branch name for a worktree slug. */ +export function worktreeBranchForSlug(slug: string): string { + return `${WORKTREE_BRANCH_PREFIX}${slug}`; +} + /** * Commit message used for the baseline snapshot in worktrees. * After overlaying the user's dirty state (tracked changes + untracked files), @@ -1004,7 +1013,7 @@ export class GitWorktreeService { await fs.mkdir(worktreesDir, { recursive: true }); const worktreePath = path.join(worktreesDir, slug); - if (await this.pathExists(worktreePath)) { + if (await fileExists(worktreePath)) { const error = `Worktree already exists at ${worktreePath}`; debugLogger.warn(`createUserWorktree: ${error}`); return { success: false, error }; @@ -1017,7 +1026,7 @@ export class GitWorktreeService { await this.ensureWorktreesGitignored(); const base = baseBranch || (await this.getCurrentBranch()); - const branchName = `worktree-${slug}`; + const branchName = worktreeBranchForSlug(slug); // Refuse to clobber a pre-existing branch with the same name. Use // `git show-ref --verify --quiet refs/heads/` (exit 0 → @@ -1103,24 +1112,25 @@ export class GitWorktreeService { const qwenDir = path.join(this.sourceRepoPath, '.qwen'); await fs.mkdir(qwenDir, { recursive: true }); const gitignorePath = path.join(qwenDir, '.gitignore'); + // `flag: 'wx'` is "open for write, fail if exists" — one atomic + // syscall that handles the "preserve user-curated file" case + // without the `fs.access` + `fs.writeFile` TOCTOU race two + // concurrent agent invocations would otherwise hit. try { - await fs.access(gitignorePath); - return; - } catch { - // File missing — fall through to write a default. + await fs.writeFile( + gitignorePath, + `# Auto-generated by qwen-code.\n${WORKTREES_DIR}/\n`, + { encoding: 'utf8', flag: 'wx' }, + ); + } catch (error) { + if (isNodeError(error) && error.code === 'EEXIST') { + return; // User-curated file already in place. + } + throw error; } - await fs.writeFile( - gitignorePath, - // Keep ephemeral worktrees out of the parent repo's git status, - // glob/grep results, and bundling tools. Users can edit this - // file freely; we only write it when it does not already exist. - `# Auto-generated by qwen-code.\n${WORKTREES_DIR}/\n`, - 'utf8', - ); } catch (error) { // Best-effort: if writing the gitignore fails (read-only fs, etc.) - // it is not worth aborting the worktree creation. Surface in debug - // logs only. + // it is not worth aborting the worktree creation. debugLogger.warn( `ensureWorktreesGitignored failed (non-fatal): ${error}`, ); @@ -1146,7 +1156,7 @@ export class GitWorktreeService { branchPreserved?: boolean; }> { const worktreePath = this.getUserWorktreePath(slug); - const branchName = `worktree-${slug}`; + const branchName = worktreeBranchForSlug(slug); const removed = await this.removeWorktree(worktreePath); if (!removed.success) { @@ -1207,7 +1217,7 @@ export class GitWorktreeService { * to preserving rather than destroying the worktree. */ async hasUnmergedWorktreeCommits(slug: string): Promise { - const branchName = `worktree-${slug}`; + const branchName = worktreeBranchForSlug(slug); try { const tipSha = (await this.git.revparse([branchName])).trim(); if (!tipSha) return true; diff --git a/packages/core/src/services/worktreeCleanup.ts b/packages/core/src/services/worktreeCleanup.ts index ed47473c1a6..348fc57df3a 100644 --- a/packages/core/src/services/worktreeCleanup.ts +++ b/packages/core/src/services/worktreeCleanup.ts @@ -10,14 +10,10 @@ import { simpleGit } from 'simple-git'; import { AGENT_WORKTREE_SLUG_PATTERN, GitWorktreeService, + worktreeBranchForSlug, } from './gitWorktreeService.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -// Branch name produced by `GitWorktreeService.createUserWorktree`: the -// directory slug prefixed with `worktree-`. Re-derived here rather than -// imported to keep this module's only external dep on the service light. -const branchNameForSlug = (slug: string): string => `worktree-${slug}`; - const debugLogger = createDebugLogger('WORKTREE_CLEANUP'); /** @@ -107,8 +103,13 @@ export async function cleanupStaleAgentWorktrees( if (mtimeMs >= cutoffDate) continue; // Fail-closed: any sign of in-progress work or unmerged commits → keep. - if (await hasTrackedChanges(worktreePath)) continue; - if (await service.hasUnmergedWorktreeCommits(entry.name)) continue; + // Run both checks concurrently — neither depends on the other and each + // spawns its own git invocation. + const [dirty, unmerged] = await Promise.all([ + hasTrackedChanges(worktreePath), + service.hasUnmergedWorktreeCommits(entry.name), + ]); + if (dirty || unmerged) continue; const result = await service.removeUserWorktree(entry.name, { deleteBranch: true, @@ -126,7 +127,7 @@ export async function cleanupStaleAgentWorktrees( // grepping logs can spot orphan branches. debugLogger.warn( `Removed stale agent worktree ${worktreePath} but kept branch ` + - `${branchNameForSlug(entry.name)} (unmerged commits at delete time)`, + `${worktreeBranchForSlug(entry.name)} (unmerged commits at delete time)`, ); } else { debugLogger.debug(`Removed stale agent worktree ${worktreePath}`); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 1ad35047805..6c56bc3e189 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1144,24 +1144,26 @@ class AgentToolInvocation extends BaseToolInvocation { // produce a bogus `[worktree preserved: ]` suffix. worktreeIsolation = null; const wtService = new GitWorktreeService(isolation.repoRoot); - let hasChanges = true; - try { - hasChanges = await wtService.hasWorktreeChanges(isolation.path); - } catch (error) { - debugLogger.warn( - `[Agent] hasWorktreeChanges failed for ${isolation.path}: ${error}`, - ); - } - let hasUnmerged = true; - try { - hasUnmerged = await wtService.hasUnmergedWorktreeCommits( - isolation.slug, - ); - } catch (error) { - debugLogger.warn( - `[Agent] hasUnmergedWorktreeCommits failed for ${isolation.slug}: ${error}`, - ); - } + // The two checks have no data dependency on each other and each + // spawns its own `git` invocation. Run them concurrently so + // cleanup wall-clock on the common case is the slower of the two + // instead of their sum. + const [hasChanges, hasUnmerged] = await Promise.all([ + wtService.hasWorktreeChanges(isolation.path).catch((error) => { + debugLogger.warn( + `[Agent] hasWorktreeChanges failed for ${isolation.path}: ${error}`, + ); + // Fail-closed: assume changes exist so we preserve. + return true; + }), + wtService.hasUnmergedWorktreeCommits(isolation.slug).catch((error) => { + debugLogger.warn( + `[Agent] hasUnmergedWorktreeCommits failed for ${isolation.slug}: ${error}`, + ); + // Fail-closed: assume uncovered work exists so we preserve. + return true; + }), + ]); if (hasChanges || hasUnmerged) { debugLogger.info( `[Agent] Preserving isolation worktree ${isolation.path} ` + @@ -1393,36 +1395,14 @@ class AgentToolInvocation extends BaseToolInvocation { // override to the worktree path so the subagent's tools cannot // leak into the parent project tree. // - // Two layers of override are needed because Config getters mix - // "read the field" and "read the getter": - // - // 1. Field shadow on `targetDir` and `cwd`. Several Config methods - // (e.g. `getProjectRoot`, `getFileService`, - // `cleanupStaleAgentWorktrees(this.targetDir)` paths) read - // `this.targetDir` directly rather than going through a getter. - // `Object.create(base)` does not shadow fields without an - // own-property assignment, so any direct read still resolves - // via the prototype chain to the parent's `targetDir`. Setting - // `ov.targetDir` (and `ov.cwd`) shadows the field for those - // paths. - // - // 2. Getter overrides on `getTargetDir`/`getCwd`/`getWorkingDir`/ - // `getProjectRoot` for the same reason in reverse: they call - // `this.targetDir` on the override, which is what we want, but - // explicit getters defined on `base` would still call methods - // on `this` that read other fields. Overriding the getters - // keeps the contract obvious to grep. - // - // 3. `getFileService()` returns a memoised `FileDiscoveryService` - // constructed against the parent's `targetDir`. We must - // return a service rooted at the worktree so .gitignore / - // .qwenignore filtering treats the worktree as the project - // root. - // - // 4. `getWorkspaceContext()` is the membership check used by - // Edit/Write/Read to refuse out-of-workspace edits. We rebuild - // it pointing at the worktree only so the subagent cannot - // write to absolute paths under the parent project. + // We override at two layers because Config getters mix direct + // field reads and getter calls. Shadowing only the methods would + // leave call sites like `this.targetDir` (e.g. inside + // `getProjectRoot`, `getFileService`) resolving via the + // prototype chain to the parent's `targetDir` — JS does not + // promote a getter assignment to a field shadow. Setting both + // `ov.targetDir` (own-property field) AND `ov.getTargetDir` + // (own-property method) covers both lookup paths. if (worktreeIsolation) { const wtPath = worktreeIsolation.path; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1478,10 +1458,6 @@ class AgentToolInvocation extends BaseToolInvocation { taskPrompt = `${notice}\n\n${taskPrompt}`; } - // (cleanupWorktreeIsolation and formatWorktreeSuffix are hoisted - // to execute()'s outer scope — see the top of this method — so - // the outer catch can also invoke them.) - const contextState = new ContextState(); contextState.set('task_prompt', taskPrompt); @@ -1987,19 +1963,11 @@ class AgentToolInvocation extends BaseToolInvocation { this.eventEmitter.on(AgentEventType.TOOL_CALL, onFgToolCall); this.eventEmitter.on(AgentEventType.USAGE_METADATA, onFgUsageMetadata); - // Tracks whether the cleanup helper has already run, so the - // finally block can skip a duplicate call when the try block - // already ran it. Without this, a worktree provisioned for an - // isolation run would leak when `runFramed()` throws — the catch - // path is the outer execute() catch, which is too far away to - // see the closure. - let worktreeCleanupRan = false; try { await runFramed(); const finalText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); const wtSuffix = formatWorktreeSuffix(await cleanupWorktreeIsolation()); - worktreeCleanupRan = true; if (terminateMode === AgentTerminateMode.ERROR) { return { llmContent: (finalText || 'Subagent execution failed.') + wtSuffix, @@ -2033,16 +2001,15 @@ class AgentToolInvocation extends BaseToolInvocation { } finally { // Mirror the background path: ensure the isolation worktree is // reaped on every termination shape (success, failure, cancel, - // and any uncaught throw inside runFramed). The success path - // already invoked the helper; this fallback only fires when - // the try block bailed before reaching it. - if (!worktreeCleanupRan) { - try { - await cleanupWorktreeIsolation(); - } catch { - // Helper logs its own failures; never mask the original - // error path with cleanup noise. - } + // and any uncaught throw inside runFramed). The helper itself + // nulls `worktreeIsolation` on its first call (see the comment + // at its definition), so this fallback fires once at most even + // when the success path already ran it. + try { + await cleanupWorktreeIsolation(); + } catch { + // Helper logs its own failures; never mask the original + // error path with cleanup noise. } this.eventEmitter.off(AgentEventType.TOOL_CALL, onFgToolCall); this.eventEmitter.off(AgentEventType.USAGE_METADATA, onFgUsageMetadata); diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index 1e1145f85ab..d98e1b8452b 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -9,7 +9,10 @@ import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { Config } from '../config/config.js'; import type { PermissionDecision } from '../permissions/types.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; -import { GitWorktreeService } from '../services/gitWorktreeService.js'; +import { + GitWorktreeService, + worktreeBranchForSlug, +} from '../services/gitWorktreeService.js'; import * as fs from 'node:fs/promises'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -95,7 +98,7 @@ class ExitWorktreeInvocation extends BaseToolInvocation< projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); const worktreePath = service.getUserWorktreePath(this.params.name); - const branch = `worktree-${this.params.name}`; + const branch = worktreeBranchForSlug(this.params.name); // Confirm the worktree directory actually exists before doing anything. let exists = false; From 1d7da201de7fd456ae8e2d0c0e44f9df0e06953f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 17:06:48 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix(worktree):=20address=20review=20round?= =?UTF-8?q?=205=20=E2=80=94=20design-doc=20commitments=20+=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five critical findings + four suggestions, all closed. Critical: 1. Wrong base branch for agent isolation. `createUserWorktree(slug)` with no `baseBranch` arg fell back to `getCurrentBranch()` on the **main** working tree, returning `main` regardless of which branch the user was actually on. A subagent invoked from `feature-x` would silently start from `main` and produce diffs against the wrong baseline. `enter_worktree` had the same bug. Both now resolve the parent's current branch first and pass it explicitly. Verified end-to-end: `git checkout feature-x` → `enter_worktree` → worktree HEAD includes the feature-x commit. 2. `countWorktreeChanges` (used by `exit_worktree`'s dirty-state guard) missed `status.conflicted[]`. In simple-git that array is mutually exclusive with the staged/modified/etc. arrays, so a worktree mid-merge with only conflicts looked `{tracked: 0, untracked: 0}` to the guard and `action='remove'` would proceed without `discard_changes: true`. Added `+ status.conflicted.length`. 3. `exit_worktree` had no session-ownership check, contradicting the design doc's "only operates on worktrees created by THIS session". In yolo mode a prompt injection could enumerate `.qwen/worktrees/` and pass any name to drop another session's work. Now: `enter_worktree` and agent isolation write a `.qwen-session` marker into the worktree at provisioning time; `exit_worktree action='remove'` reads it and refuses if it does not match the current `Config.getSessionId()`. Worktrees from before this guard (no marker file) are treated as "owner unknown" — allowed with a warn log so the change is observable. 4. `enter_worktree` did not refuse nested invocations from inside an existing worktree, contradicting the design doc. Now rejects any cwd containing `.qwen/worktrees/` as a path component, with a clear "Already inside a git worktree…" message. Verified: enter from inside a worktree returns is_error with that text. 6. `hasTrackedChanges` (cleanup sweep) had the same `conflicted[]` gap. Rewrote to use raw `git status --porcelain --untracked-files=no` which lists every tracked change including `UU` conflict markers in a single git call and explicitly skips the untracked walk (the prior comment claimed to skip it, but `status()` always does the scan). Suggestion: 7. `buildWorktreeNotice` now receives the parent agent's actual `getTargetDir()` again (was switched to `repoRoot` in round 3 on a different reviewer's suggestion; round-5 caught that the model's inherited paths reference the parent's cwd, not necessarily the repo root, so the prior behaviour was correct). 8. Startup sweep now does `fs.access(/.qwen/worktrees)` *before* importing GitWorktreeService and spawning `git rev-parse --show-toplevel`. The git probe is reserved for users who actually have a worktrees directory locally — 99% of users pay only one syscall on startup. 9. Tests: - New `exit-worktree.test.ts` covers metadata, validation, `getDefaultPermission` (ask vs allow), and getDescription. - `agent.test.ts` adds three `validateToolParams` cases for the `isolation` parameter (accepted with subagent_type, rejected without, rejected for non-"worktree" values). - `enter-worktree.test.ts` adds round-trip tests for `writeWorktreeSessionMarker` / `readWorktreeSessionMarker` plus a `worktreeBranchForSlug` sanity check. - Total: 101 tests pass (was 86 → +15). Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 22 +++++ .../core/src/services/gitWorktreeService.ts | 50 +++++++++- packages/core/src/services/worktreeCleanup.ts | 38 +++---- packages/core/src/tools/agent/agent.test.ts | 30 ++++++ packages/core/src/tools/agent/agent.ts | 50 ++++++++-- .../core/src/tools/enter-worktree.test.ts | 75 ++++++++++++++ packages/core/src/tools/enter-worktree.ts | 57 ++++++++++- packages/core/src/tools/exit-worktree.test.ts | 98 +++++++++++++++++++ packages/core/src/tools/exit-worktree.ts | 29 +++++- 9 files changed, 420 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/tools/exit-worktree.test.ts diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a45d5c63723..83112f1bede 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -7,6 +7,7 @@ // Node built-ins import type { EventEmitter } from 'node:events'; import * as fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; import * as path from 'node:path'; import process from 'node:process'; @@ -1257,6 +1258,27 @@ export class Config { if (!this.getBareMode()) { void (async () => { try { + // Cheap fast-bail BEFORE the git probe: 99% of users never + // touch worktrees, and a single `fs.access` saves spawning a + // git subprocess on every CLI start. The fallback location + // (in the unusual case where `targetDir` is not the repo + // root) is caught when `cleanupStaleAgentWorktrees` does its + // own `fs.access` against the resolved `worktreesDir`. + const localWorktreesDir = path.join( + this.targetDir, + '.qwen', + 'worktrees', + ); + try { + await fsPromises.access(localWorktreesDir); + } catch { + // No `.qwen/worktrees` under the current cwd. The repo + // root may differ (monorepo subdir launch), but + // `cleanupStaleAgentWorktrees` will fast-bail there too, + // so resolving the root for the no-worktrees case is + // wasted work. + return; + } const probe = new GitWorktreeService(this.targetDir); const root = (await probe.getRepoTopLevel()) ?? this.targetDir; const removed = await cleanupStaleAgentWorktrees(root); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index b5ef92f781a..9ce71a41eff 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -27,6 +27,48 @@ export function worktreeBranchForSlug(slug: string): string { return `${WORKTREE_BRANCH_PREFIX}${slug}`; } +/** + * Filename of the in-worktree session marker. Created at worktree + * provisioning time and consulted by `exit_worktree` to decide + * whether the current session is allowed to drop the worktree. The + * file lives outside the working tree (it is .gitignored as part of + * `.qwen/worktrees/.gitignore`) so it cannot leak into commits. + */ +export const WORKTREE_SESSION_FILE = '.qwen-session'; + +/** Writes the owning session id into the worktree's session marker. */ +export async function writeWorktreeSessionMarker( + worktreePath: string, + sessionId: string, +): Promise { + await fs.writeFile( + path.join(worktreePath, WORKTREE_SESSION_FILE), + sessionId, + 'utf8', + ); +} + +/** + * Reads the owning session id stored at worktree provisioning time. + * Returns `null` when the marker is missing or unreadable — callers + * decide whether to treat that as "owner unknown, refuse" or "owner + * unknown, allow with explicit override". + */ +export async function readWorktreeSessionMarker( + worktreePath: string, +): Promise { + try { + const raw = await fs.readFile( + path.join(worktreePath, WORKTREE_SESSION_FILE), + 'utf8', + ); + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + /** * Commit message used for the baseline snapshot in worktrees. * After overlaying the user's dirty state (tracked changes + untracked files), @@ -1279,12 +1321,18 @@ export class GitWorktreeService { try { const wtGit = simpleGit(worktreePath); const status = await wtGit.status(); + // `conflicted` is mutually exclusive with the other arrays in + // simple-git's status — a worktree mid-merge with no other + // edits would otherwise read as `{tracked: 0, untracked: 0}` + // and slip past the dirty-state guard in `exit_worktree`, + // discarding the merge resolution. Treat as tracked changes. const tracked = status.staged.length + status.modified.length + status.deleted.length + status.renamed.length + - status.created.length; + status.created.length + + status.conflicted.length; const untracked = status.not_added.length; return { tracked, untracked }; } catch { diff --git a/packages/core/src/services/worktreeCleanup.ts b/packages/core/src/services/worktreeCleanup.ts index 348fc57df3a..60868bbbfff 100644 --- a/packages/core/src/services/worktreeCleanup.ts +++ b/packages/core/src/services/worktreeCleanup.ts @@ -146,25 +146,27 @@ export async function cleanupStaleAgentWorktrees( async function hasTrackedChanges(worktreePath: string): Promise { try { const wtGit = simpleGit(worktreePath); - const status = await wtGit.status(); - // Skip the untracked-files scan: untracked files in a long-dead agent - // worktree are typically build artifacts, not user work — and listing - // them is the slowest part of `git status` on large repos. Tracked - // changes (staged, modified, deleted, renamed, created) are the signal - // that work is in progress. - return ( - status.staged.length > 0 || - status.modified.length > 0 || - status.deleted.length > 0 || - status.renamed.length > 0 || - status.created.length > 0 - ); + // `git status --porcelain --untracked-files=no` lists every tracked + // change (staged, unstaged, conflicted — `UU` lines) and skips the + // untracked-file scan that simple-git's `status()` runs + // unconditionally. Untracked files in a long-dead agent worktree + // are typically build artifacts, not user work — and the + // untracked walk is the slowest part of `git status` on large + // repos. The previous implementation manually enumerated + // `status.staged/modified/...` which silently missed + // `conflicted[]` (mutually exclusive with the others in + // simple-git), so a worktree mid-merge looked "clean" and would + // be swept. + const out = await wtGit.raw([ + 'status', + '--porcelain', + '--untracked-files=no', + ]); + return out.trim().length > 0; } catch (error) { - // Fail-closed (assume dirty so the worktree is preserved) but log - // so the operator can distinguish "has real changes — leave alone" - // from "git index is unreadable — repo may be corrupt". Without - // this, a permission error or unmounted filesystem would silently - // keep the worktree forever with no breadcrumb. + // Fail-closed (preserve worktree) and log so a permission error or + // unmounted filesystem leaves a breadcrumb instead of being + // indistinguishable from "has real changes". debugLogger.warn( `hasTrackedChanges: cannot inspect ${worktreePath} — assuming dirty: ${error}`, ); diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 5be5deb1e19..6a6dc050818 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -315,6 +315,36 @@ describe('AgentTool', () => { 'Subagent "non-existent" not found. Available subagents: file-search, code-review', ); }); + + it('accepts isolation="worktree" when subagent_type is set', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + isolation: 'worktree', + }), + ).toBeNull(); + }); + + it('rejects isolation values other than "worktree"', () => { + expect( + agentTool.validateToolParams({ + ...validParams, + // @ts-expect-error: deliberately wrong enum value + isolation: 'remote', + }), + ).toMatch(/isolation/i); + }); + + it('rejects isolation without subagent_type (fork is not isolatable)', () => { + const { subagent_type: _ignored, ...forkParams } = validParams; + void _ignored; + expect( + agentTool.validateToolParams({ + ...forkParams, + isolation: 'worktree', + }), + ).toMatch(/subagent_type/i); + }); }); describe('refreshSubagents', () => { diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 6c56bc3e189..4e1bcdf1578 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -45,6 +45,7 @@ import { import { generateAgentWorktreeSlug, GitWorktreeService, + writeWorktreeSessionMarker, } from '../../services/gitWorktreeService.js'; import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; import { WorkspaceContext } from '../../utils/workspaceContext.js'; @@ -1348,7 +1349,26 @@ class AgentToolInvocation extends BaseToolInvocation { const wtService = projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); const slug = generateAgentWorktreeSlug(); - const created = await wtService.createUserWorktree(slug); + // Anchor the isolation worktree to the parent's currently + // checked-out branch. Without an explicit base, + // `createUserWorktree` falls back to whichever branch the main + // working tree happens to be on — which silently becomes `main` + // when the user invoked the agent from a feature branch, from + // inside another user worktree, or from a detached HEAD set up + // by the test harness. The subagent would then see the wrong + // code and produce diffs against an unrelated baseline. + let parentBranch: string | undefined; + try { + parentBranch = await wtService.getCurrentBranch(); + } catch (error) { + // Best-effort: leave undefined so createUserWorktree's own + // fallback runs. A debug log lets operators see when we hit + // the fallback path. + debugLogger.warn( + `[Agent] getCurrentBranch failed at ${projectRoot}: ${error}`, + ); + } + const created = await wtService.createUserWorktree(slug, parentBranch); if (!created.success || !created.worktree) { return failWorktreeProvisioning( `Failed to create isolation worktree: ${created.error ?? 'unknown error'}`, @@ -1360,6 +1380,21 @@ class AgentToolInvocation extends BaseToolInvocation { branch: created.worktree.branch, repoRoot: projectRoot, }; + + // Tag the isolation worktree with the parent session id for + // consistency with `enter_worktree` (ownership-aware + // `exit_worktree` refuses to drop worktrees from other + // sessions). Best-effort. + try { + await writeWorktreeSessionMarker( + created.worktree.path, + this.config.getSessionId(), + ); + } catch (error) { + debugLogger.warn( + `[Agent] failed to write session marker at ${created.worktree.path}: ${error}`, + ); + } } // Resolve the subagent's permission mode before creating it @@ -1445,14 +1480,15 @@ class AgentToolInvocation extends BaseToolInvocation { // above guarantees correctness; the notice reduces user-visible // surprises when the model summarises file paths. // - // The notice's "parent cwd" must be the repo top-level (where the - // user's main work lives), not whatever subdirectory the user - // happened to launch `qwen` from — otherwise the path-translation - // guidance would tell the agent to map worktree paths back to a - // monorepo subdir that may not contain the parent file at all. + // "parent cwd" is the parent agent's actual `getTargetDir()` — + // the directory the inherited conversation context speaks from. + // Using the repo top-level here would mistranslate paths the + // parent referenced as `./packages/core/foo` when the parent + // was running from `packages/core/`. Round-5 review caught this: + // the model's mental map is the parent's cwd, not the repo root. if (worktreeIsolation) { const notice = buildWorktreeNotice( - worktreeIsolation.repoRoot, + this.config.getTargetDir(), worktreeIsolation.path, ); taskPrompt = `${notice}\n\n${taskPrompt}`; diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index f8847fd3489..af66cd4598a 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -91,6 +91,81 @@ describe('generateAgentWorktreeSlug', () => { }); }); +describe('worktreeBranchForSlug', () => { + it('prefixes the slug with WORKTREE_BRANCH_PREFIX', async () => { + const { worktreeBranchForSlug, WORKTREE_BRANCH_PREFIX } = await import( + '../services/gitWorktreeService.js' + ); + expect(worktreeBranchForSlug('feat-x')).toBe( + `${WORKTREE_BRANCH_PREFIX}feat-x`, + ); + expect(worktreeBranchForSlug('agent-aabbccd')).toBe( + `${WORKTREE_BRANCH_PREFIX}agent-aabbccd`, + ); + }); +}); + +describe('session marker round-trip', () => { + it('write then read returns the same session id', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + const { + writeWorktreeSessionMarker, + readWorktreeSessionMarker, + WORKTREE_SESSION_FILE, + } = await import('../services/gitWorktreeService.js'); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-session-')); + try { + await writeWorktreeSessionMarker(tmp, 'session-abc-123'); + const got = await readWorktreeSessionMarker(tmp); + expect(got).toBe('session-abc-123'); + const onDisk = await fs.readFile( + path.join(tmp, WORKTREE_SESSION_FILE), + 'utf8', + ); + expect(onDisk.trim()).toBe('session-abc-123'); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it('returns null when the marker file is missing', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + const { readWorktreeSessionMarker } = await import( + '../services/gitWorktreeService.js' + ); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-session-')); + try { + expect(await readWorktreeSessionMarker(tmp)).toBeNull(); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it('returns null when the marker file is empty / whitespace', async () => { + const fs = await import('node:fs/promises'); + const pathMod = await import('node:path'); + const os = await import('node:os'); + const { readWorktreeSessionMarker, WORKTREE_SESSION_FILE } = await import( + '../services/gitWorktreeService.js' + ); + const tmp = await fs.mkdtemp(pathMod.join(os.tmpdir(), 'qwen-wt-session-')); + try { + await fs.writeFile( + pathMod.join(tmp, WORKTREE_SESSION_FILE), + ' \n \n', + 'utf8', + ); + expect(await readWorktreeSessionMarker(tmp)).toBeNull(); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); +}); + describe('GitWorktreeService.generateAutoSlug', () => { it('produces a slug matching the {adj}-{noun}-{6hex} pattern', () => { for (let i = 0; i < 50; i++) { diff --git a/packages/core/src/tools/enter-worktree.ts b/packages/core/src/tools/enter-worktree.ts index e8cf6a3ba61..827dcdaecc6 100644 --- a/packages/core/src/tools/enter-worktree.ts +++ b/packages/core/src/tools/enter-worktree.ts @@ -8,7 +8,10 @@ import type { ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { Config } from '../config/config.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; -import { GitWorktreeService } from '../services/gitWorktreeService.js'; +import { + GitWorktreeService, + writeWorktreeSessionMarker, +} from '../services/gitWorktreeService.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('ENTER_WORKTREE'); @@ -66,6 +69,26 @@ class EnterWorktreeInvocation extends BaseToolInvocation< async execute(_signal: AbortSignal): Promise { const cwd = this.config.getTargetDir(); + // Refuse nested worktree creation. If the caller's cwd is itself + // already inside `.qwen/worktrees//`, a fresh worktree would + // be provisioned at `/.qwen/worktrees//` — but the + // model's mental model and inherited file paths would still + // reference the outer worktree. The resulting handle confusion + // typically leaves the inner worktree orphaned on exit. + // + // The check is conservative: any path component named + // `.qwen/worktrees` somewhere in cwd qualifies. We also forbid + // sentinel "inside a worktree" markers that an `enter_worktree` + // session leaves behind (writeSessionMarker, below). + if (/\.qwen[\\/]worktrees[\\/]/.test(cwd)) { + const reason = + 'Already inside a git worktree. Call exit_worktree first, ' + + 'or return to the main repository checkout before creating a ' + + 'new worktree.'; + debugLogger.warn(`enter_worktree: ${reason} (cwd=${cwd})`); + return errorResult(reason); + } + // First-pass service rooted at cwd, only to find the repo top-level. // We can't use cwd as the worktree anchor because launching from a // monorepo subdirectory would scatter `.qwen/worktrees/` under each @@ -109,13 +132,43 @@ class EnterWorktreeInvocation extends BaseToolInvocation< return errorResult(validation); } - const result = await service.createUserWorktree(slug); + // Anchor at the parent session's currently checked-out branch. + // Without an explicit base, `createUserWorktree` falls back to + // whichever branch the main working tree has checked out, which is + // not necessarily where the user is working (e.g. they invoked + // qwen from a feature branch but the main working tree still has + // `main` checked out). + let baseBranch: string | undefined; + try { + baseBranch = await service.getCurrentBranch(); + } catch (error) { + debugLogger.warn( + `enter_worktree: getCurrentBranch failed at ${projectRoot}: ${error}`, + ); + } + const result = await service.createUserWorktree(slug, baseBranch); if (!result.success || !result.worktree) { const reason = result.error ?? 'Failed to create worktree.'; debugLogger.warn(`enter_worktree: createUserWorktree failed: ${reason}`); return errorResult(reason); } + // Tag the worktree with the current session id so a future + // `exit_worktree action='remove'` from a different session refuses + // to drop someone else's work. Best-effort: a write failure does + // not abort the creation (the worktree is still usable; ownership + // checks will treat unmarked worktrees as "owner unknown"). + try { + await writeWorktreeSessionMarker( + result.worktree.path, + this.config.getSessionId(), + ); + } catch (error) { + debugLogger.warn( + `enter_worktree: failed to write session marker at ${result.worktree.path}: ${error}`, + ); + } + const output: EnterWorktreeOutput = { worktreePath: result.worktree.path, worktreeBranch: result.worktree.branch, diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts new file mode 100644 index 00000000000..fe2fdd1eae7 --- /dev/null +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ExitWorktreeTool } from './exit-worktree.js'; +import type { Config } from '../config/config.js'; + +function makeMockConfig(targetDir = '/tmp/mock-repo'): Config { + return { + getTargetDir: vi.fn(() => targetDir), + getSessionId: vi.fn(() => 'mock-session-id'), + } as unknown as Config; +} + +describe('ExitWorktreeTool', () => { + describe('metadata', () => { + it('exposes the correct tool name', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect(tool.name).toBe('exit_worktree'); + expect(tool.displayName).toBe('ExitWorktree'); + }); + }); + + describe('validateToolParams', () => { + it('requires a non-empty name', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + // @ts-expect-error: deliberately invalid input + expect(tool.validateToolParams({ name: '', action: 'keep' })).toMatch( + /non-empty/i, + ); + }); + + it('requires action to be keep or remove', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect( + tool.validateToolParams({ + name: 'foo', + action: 'destroy' as 'keep' | 'remove', + }), + ).toMatch(/keep.*remove/i); + expect( + tool.validateToolParams({ name: 'foo', action: 'keep' }), + ).toBeNull(); + expect( + tool.validateToolParams({ name: 'foo', action: 'remove' }), + ).toBeNull(); + }); + + it('rejects slugs that would resolve outside the worktrees dir', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect( + tool.validateToolParams({ name: 'a/b', action: 'remove' }), + ).not.toBeNull(); + expect( + tool.validateToolParams({ name: '../etc', action: 'remove' }), + ).not.toBeNull(); + }); + + it('rejects discard_changes when it is not a boolean', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + expect( + tool.validateToolParams({ + name: 'foo', + action: 'remove', + // @ts-expect-error: deliberately wrong type + discard_changes: 'yes', + }), + ).toMatch(/boolean/i); + }); + }); + + describe('default permission', () => { + it("returns 'ask' when action is 'remove'", async () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const inv = tool.build({ name: 'foo', action: 'remove' }); + expect(await inv.getDefaultPermission()).toBe('ask'); + }); + + it("returns 'allow' when action is 'keep'", async () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const inv = tool.build({ name: 'foo', action: 'keep' }); + expect(await inv.getDefaultPermission()).toBe('allow'); + }); + }); + + describe('getDescription', () => { + it('mentions remove vs keep', () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const remove = tool.build({ name: 'foo', action: 'remove' }); + expect(remove.getDescription()).toMatch(/remove/i); + const keep = tool.build({ name: 'foo', action: 'keep' }); + expect(keep.getDescription()).toMatch(/keep/i); + }); + }); +}); diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index d98e1b8452b..9a7d2f5566d 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -11,6 +11,7 @@ import type { PermissionDecision } from '../permissions/types.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import { GitWorktreeService, + readWorktreeSessionMarker, worktreeBranchForSlug, } from '../services/gitWorktreeService.js'; import * as fs from 'node:fs/promises'; @@ -129,8 +130,34 @@ class ExitWorktreeInvocation extends BaseToolInvocation< }; } - // action === 'remove' — two independent guards: + // action === 'remove' — three independent guards: // + // 0. Session ownership: refuse to drop a worktree that was created + // by a different session. Without this, a prompt injection (or + // just a confused model) in session A could enumerate + // `.qwen/worktrees/` and call `exit_worktree` with a name + // belonging to session B, destroying its work. Worktrees + // created before this guard existed lack the marker; we treat + // those as "owner unknown" and allow removal (matches prior + // behaviour) but log so operators can see when the guard is + // bypassed. + const owner = await readWorktreeSessionMarker(worktreePath); + const currentSessionId = this.config.getSessionId(); + if (owner !== null && owner !== currentSessionId) { + return errorResult( + `Refusing to remove worktree "${this.params.name}" — it was ` + + `created by a different session (owner=${owner}). Resume the ` + + `owning session to drop it, or remove it manually with ` + + `\`git worktree remove ${worktreePath}\`.`, + ); + } + if (owner === null) { + debugLogger.warn( + `exit_worktree: worktree ${worktreePath} has no session marker; ` + + `allowing removal from session ${currentSessionId}`, + ); + } + // 1. Uncommitted edits (working tree dirty). Bypassed by // `discard_changes: true`. // 2. Commits on the worktree branch that no other local branch or From 4bf018cc91609dee4ab2165b010db014c668bfb6 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 17:16:47 +0800 Subject: [PATCH 11/15] fix(test): drop unused @ts-expect-error in exit-worktree.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty string `''` is a valid `string` type, so the @ts-expect-error directive on `validateToolParams({ name: '', action: 'keep' })` did nothing — TypeScript correctly accepted the line, and `tsc --build` in CI reported TS2578 ("Unused '@ts-expect-error' directive"). The runtime assertion already covers the case; the directive was leftover from an earlier draft. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/tools/exit-worktree.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts index fe2fdd1eae7..ccceecb7365 100644 --- a/packages/core/src/tools/exit-worktree.test.ts +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -27,7 +27,6 @@ describe('ExitWorktreeTool', () => { describe('validateToolParams', () => { it('requires a non-empty name', () => { const tool = new ExitWorktreeTool(makeMockConfig()); - // @ts-expect-error: deliberately invalid input expect(tool.validateToolParams({ name: '', action: 'keep' })).toMatch( /non-empty/i, ); From 6ec1229bf3c6cd3d2d23f503ff9b348fc08ddd46 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 13 May 2026 17:36:17 +0800 Subject: [PATCH 12/15] fix(test): use importActual in ArenaManager mock to preserve new exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arena test mocks `gitWorktreeService.js` with a factory that returns only `{ GitWorktreeService }`. PR #4073 added several other exports to that module (`AGENT_WORKTREE_SLUG_PATTERN`, `WORKTREE_BRANCH_PREFIX`, `worktreeBranchForSlug`, `generateAgentWorktreeSlug`, `writeWorktreeSessionMarker`, `readWorktreeSessionMarker`, `WORKTREE_SESSION_FILE`). Other modules in the dep graph reach the mocked surface — most notably `worktreeCleanup.ts` imports `AGENT_WORKTREE_SLUG_PATTERN` and `worktreeBranchForSlug`, and now reaches the mock via the static `config.ts` → `worktreeCleanup.ts` import chain added in the self-review pass. The Arena test failed at module-load with: Caused by: Error: [vitest] No "AGENT_WORKTREE_SLUG_PATTERN" export is defined on the "../../services/gitWorktreeService.js" mock. Did you forget to return it from "vi.mock"? Use `importOriginal` to capture every real export, spread it into the return object, and only replace `GitWorktreeService` (the class the test actually needs to mock). The class-level mock keeps its existing static-method shims. Co-Authored-By: Claude Opus 4.7 --- .../core/src/agents/arena/ArenaManager.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index d1fb6b2fefe..744c059a81b 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -29,7 +29,19 @@ vi.mock('../index.js', async (importOriginal) => { // Mock GitWorktreeService to avoid real git operations. // The class mock includes static methods used by ArenaManager. -vi.mock('../../services/gitWorktreeService.js', () => { +// +// Preserve every other export via `importActual` so unrelated +// consumers of the module (e.g. `worktreeCleanup.ts` → +// `AGENT_WORKTREE_SLUG_PATTERN`, `worktreeBranchForSlug`, +// `generateAgentWorktreeSlug`, `WORKTREE_BRANCH_PREFIX`, +// session-marker helpers) keep working. Without this, vitest replaces +// the entire module surface and any static import of those constants +// elsewhere in the dependency graph blows up at load time. +vi.mock('../../services/gitWorktreeService.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../services/gitWorktreeService.js') + >(); const MockClass = vi.fn().mockImplementation(() => ({ checkGitAvailable: vi.fn().mockResolvedValue({ available: true }), isGitRepository: vi.fn().mockResolvedValue(true), @@ -47,7 +59,7 @@ vi.mock('../../services/gitWorktreeService.js', () => { (MockClass as unknown as Record)['getWorktreesDir'] = ( sessionId: string, ) => path.join(os.tmpdir(), 'arena-mock', sessionId, 'worktrees'); - return { GitWorktreeService: MockClass }; + return { ...actual, GitWorktreeService: MockClass }; }); // Mock the Config class From b374187fa2d803e17424f18abcd5caa5db7f62c6 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 14 May 2026 10:30:34 +0800 Subject: [PATCH 13/15] fix(worktree): address review round 6 (5 critical + 6 suggestions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biggest item — #1 — is a self-inflicted regression from round 5: the new agent- prefix reservation in `validateUserWorktreeSlug` rejected EVERY slug that `generateAgentWorktreeSlug` produces, since that helper emits exactly `agent-<7hex>`. Net effect: every `AgentTool isolation: 'worktree'` invocation failed at validation. The reservation now allows the canonical pattern through (everything the helper can produce) and only rejects user-chosen `agent-*` names that don't match it. Added a round-trip regression guard: 50 `generateAgentWorktreeSlug()` outputs are fed back through `validateUserWorktreeSlug` and must all pass. Other critical fixes: 2. `hasWorktreeChanges` (used by agent isolation cleanup) was the one remaining caller relying solely on `status.isClean()`. Defensive `|| status.conflicted.length > 0` so a future simple-git bookkeeping change can't let a mid-merge worktree appear clean and get auto-deleted. 3. `readWorktreeSessionMarker` swallowed every I/O error as "marker missing", which let a disk error / EACCES silently bypass the session-ownership guard. ENOENT is still treated as missing (legitimate); every other code now logs. 4. `exit_worktree` `fs.stat` catch was the same shape — every error collapsed to "Worktree not found". ENOENT → not found; everything else logs and returns a distinct "cannot access" error. 5. `cleanupStaleAgentWorktrees` `fs.stat` catch was again the same. ENOENT → silently skip (entry vanished between readdir and stat); everything else logs. Suggestions: 6. Startup sweep fast-bail was running BEFORE resolving the repo top-level. For monorepo subdir launches, `targetDir/.qwen/worktrees` never exists and the sweep early-returned — permanently a no-op. Now resolves the root first, then fast-bails against the resolved `/.qwen/worktrees`. Also logs the skip case so operators can tell "skipped" from "ran, found nothing". 7. `.qwen-session` marker was visible to `git add -A` inside the worktree. Now writes a `.git/info/exclude` rule (resolved via `git rev-parse --git-dir`, since worktree `.git` is a file pointing at the parent repo's `.git/worktrees//`). Best-effort: failure to write the rule does not abort provisioning. 8. Agent isolation now refuses to provision when the parent's cwd is already inside a worktree — same regex guard as `enter_worktree`. 9. `exit_worktree`'s wrapper around `hasUnmergedWorktreeCommits` now logs at the call site so the chain (caller → reason it asked → underlying git error) is complete in operator logs. 10. Sweep now logs unconditionally at `info`. Three distinct messages: "skipped (no worktrees dir)", "ran, nothing to remove", "removed N". Tests: 11. New `execute()` coverage: • exit-worktree: session-ownership refusal, keep happy path, legacy/no-marker fallthrough with warn log, missing-worktree error, unmerged-commits guard with `discard_changes: true`, `writeWorktreeSessionMarker` round-trip. • enter-worktree: nested-guard rejection, non-git-repo error. These spin up real temp git repos (no filesystem mocking) and drive the actual tool invocation pipeline. Total: 135 tests pass (was 101 → +34). Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 57 +++--- .../core/src/services/gitWorktreeService.ts | 78 ++++++-- packages/core/src/services/worktreeCleanup.ts | 12 +- packages/core/src/tools/agent/agent.ts | 14 ++ .../core/src/tools/enter-worktree.test.ts | 78 +++++++- packages/core/src/tools/exit-worktree.test.ts | 172 +++++++++++++++++- packages/core/src/tools/exit-worktree.ts | 28 ++- 7 files changed, 389 insertions(+), 50 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 83112f1bede..a3cfda52430 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1258,39 +1258,40 @@ export class Config { if (!this.getBareMode()) { void (async () => { try { - // Cheap fast-bail BEFORE the git probe: 99% of users never - // touch worktrees, and a single `fs.access` saves spawning a - // git subprocess on every CLI start. The fallback location - // (in the unusual case where `targetDir` is not the repo - // root) is caught when `cleanupStaleAgentWorktrees` does its - // own `fs.access` against the resolved `worktreesDir`. - const localWorktreesDir = path.join( - this.targetDir, - '.qwen', - 'worktrees', - ); - try { - await fsPromises.access(localWorktreesDir); - } catch { - // No `.qwen/worktrees` under the current cwd. The repo - // root may differ (monorepo subdir launch), but - // `cleanupStaleAgentWorktrees` will fast-bail there too, - // so resolving the root for the no-worktrees case is - // wasted work. - return; - } + // Resolve the repo top-level FIRST. The previous code bailed + // on `fs.access(/.qwen/worktrees)` before resolving, + // so a monorepo subdir launch (where `targetDir` is the + // subdir, not the repo root) always early-returned and the + // sweep was permanently a no-op. Fast-bail still happens, just + // against the *correct* directory. const probe = new GitWorktreeService(this.targetDir); const root = (await probe.getRepoTopLevel()) ?? this.targetDir; - const removed = await cleanupStaleAgentWorktrees(root); - if (removed > 0) { - // Promote to `info` so operators chasing a worktree leak in - // production can grep the logs and see the sweep actually - // ran. The cleanup helper itself uses `debug`-level - // per-entry logs, which are silent at default verbosity. + const worktreesDir = path.join(root, '.qwen', 'worktrees'); + try { + await fsPromises.access(worktreesDir); + } catch { + // No worktrees directory at the repo root either; nothing + // to sweep. Log so operators can distinguish "sweep + // skipped (no worktrees)" from "sweep ran, found nothing" + // — the previous version was silent in both cases. this.debugLogger.info( - `Stale worktree sweep removed ${removed} ephemeral worktree(s) under ${root}`, + `Stale worktree sweep skipped: ${worktreesDir} does not exist`, ); + return; } + const removed = await cleanupStaleAgentWorktrees(root); + // Log unconditionally at `info` so operators chasing a + // worktree leak can distinguish the three cases that all + // looked identical before: + // • sweep skipped (no worktrees dir) — handled in the + // fast-bail branch above + // • sweep ran, found nothing ← this branch when removed === 0 + // • sweep ran, removed N stale worktrees ← this branch when removed > 0 + this.debugLogger.info( + removed > 0 + ? `Stale worktree sweep removed ${removed} ephemeral worktree(s) under ${root}` + : `Stale worktree sweep ran under ${root}: nothing to remove`, + ); } catch (error: unknown) { // Promote sweep errors to `warn` for the same reason: a // permission failure / disk full / repo-corruption case diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 9ce71a41eff..afb1c58913b 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -46,6 +46,37 @@ export async function writeWorktreeSessionMarker( sessionId, 'utf8', ); + // The marker lives inside the worktree dir so a subagent running + // `git add -A` inside it would otherwise add the session id to its + // first commit. Write a `.git/info/exclude` rule so the marker is + // ignored without requiring (or modifying) a tracked `.gitignore`. + // `.git` inside a worktree is actually a file pointing at + // `/.git/worktrees//`, so resolve `--git-dir` instead + // of joining naively. + try { + const wtGit = simpleGit(worktreePath); + const gitDir = (await wtGit.revparse(['--git-dir'])).trim(); + const excludePath = path.isAbsolute(gitDir) + ? path.join(gitDir, 'info', 'exclude') + : path.join(worktreePath, gitDir, 'info', 'exclude'); + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + let existing = ''; + try { + existing = await fs.readFile(excludePath, 'utf8'); + } catch { + // File missing — fall through to fresh write. + } + const rule = WORKTREE_SESSION_FILE; + if (!existing.split(/\r?\n/).includes(rule)) { + const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n'; + await fs.writeFile(excludePath, `${existing}${sep}${rule}\n`, 'utf8'); + } + } catch { + // Best-effort: if we can't write the exclude rule (read-only fs, + // unusual worktree layout), the marker is still functional — + // `git add -A` would just stage it. The ownership guard remains + // intact either way. + } } /** @@ -57,14 +88,22 @@ export async function writeWorktreeSessionMarker( export async function readWorktreeSessionMarker( worktreePath: string, ): Promise { + const markerPath = path.join(worktreePath, WORKTREE_SESSION_FILE); try { - const raw = await fs.readFile( - path.join(worktreePath, WORKTREE_SESSION_FILE), - 'utf8', - ); + const raw = await fs.readFile(markerPath, 'utf8'); const trimmed = raw.trim(); return trimmed.length > 0 ? trimmed : null; - } catch { + } catch (error) { + // Distinguish "marker missing" (legitimate — worktree predates the + // session-ownership guard) from "marker unreadable" (disk error, + // permission, corrupt NFS). Both still return `null`, but the + // unreadable case logs so an operator chasing a "wrong session + // bypassed the ownership guard" report has a breadcrumb. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLogger.warn( + `readWorktreeSessionMarker: cannot read ${markerPath}: ${error}`, + ); + } return null; } } @@ -1018,11 +1057,20 @@ export class GitWorktreeService { return 'Worktree name must not start with "." or "-" or contain "..".'; } if (slug.startsWith(`${AGENT_WORKTREE_PREFIX}-`)) { - return ( - `Worktree name must not start with "${AGENT_WORKTREE_PREFIX}-": that prefix ` + - `is reserved for ephemeral agent worktrees and is subject to ` + - `automatic cleanup after 30 days.` - ); + // The exact `agent-<7hex>` slugs that `generateAgentWorktreeSlug` + // produces ARE allowed — those are the legitimate ephemeral + // shape that the cleanup sweep is built around. Only reject + // user-chosen names with the same prefix that don't match the + // canonical pattern (e.g. `agent-feature`, `agent-1234567890`): + // those would either get swept after 30 days or never (if not + // matching the regex), confusing the user either way. + if (!AGENT_WORKTREE_SLUG_PATTERN.test(slug)) { + return ( + `Worktree name must not start with "${AGENT_WORKTREE_PREFIX}-": that prefix ` + + `is reserved for ephemeral agent worktrees and is subject to ` + + `automatic cleanup after 30 days.` + ); + } } return null; } @@ -1304,8 +1352,14 @@ export class GitWorktreeService { try { const wtGit = simpleGit(worktreePath); const status = await wtGit.status(); - // `not_added` covers untracked; `staged`/`modified`/etc. cover the rest. - return !status.isClean(); + // Defensive: `status.isClean()` reads several status arrays, but + // we OR with `conflicted.length` explicitly so future simple-git + // versions that change the bookkeeping cannot silently let a + // mid-merge worktree appear clean to the agent cleanup path + // (which would then delete it and lose the resolution work). + // `not_added` covers untracked; `staged`/`modified`/etc. cover + // the rest. + return !status.isClean() || status.conflicted.length > 0; } catch { return true; } diff --git a/packages/core/src/services/worktreeCleanup.ts b/packages/core/src/services/worktreeCleanup.ts index 60868bbbfff..3c0fe335c64 100644 --- a/packages/core/src/services/worktreeCleanup.ts +++ b/packages/core/src/services/worktreeCleanup.ts @@ -97,7 +97,17 @@ export async function cleanupStaleAgentWorktrees( try { const stats = await fs.stat(worktreePath); mtimeMs = stats.mtimeMs; - } catch { + } catch (error) { + // Permission error / unmounted FS / EIO → skip this entry but + // log so an operator can correlate accumulating disk usage with + // the stat failure that prevents reaping. ENOENT is the only + // truly silent case (the entry vanished between readdir and + // stat) and is also benign. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLogger.warn( + `cleanupStaleAgentWorktrees: cannot stat ${worktreePath} — skipping: ${error}`, + ); + } continue; } if (mtimeMs >= cutoffDate) continue; diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 4e1bcdf1578..1fbecbe9de5 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1330,6 +1330,20 @@ class AgentToolInvocation extends BaseToolInvocation { if (this.params.isolation === 'worktree') { const cwd = this.config.getTargetDir(); + // Refuse nested isolation. If the parent itself is already + // running inside a worktree (cwd contains `.qwen/worktrees/`), + // creating a sibling isolation worktree at the repo root + // would leave the model's mental map pointing at the outer + // worktree while the override aimed it at the inner one. + // Same guard `enter_worktree` uses. + if (/\.qwen[\\/]worktrees[\\/]/.test(cwd)) { + return failWorktreeProvisioning( + `Failed to set up worktree isolation: parent is already inside ` + + `a worktree (${cwd}). Nested isolation worktrees are not ` + + `supported — the model's inherited paths would still reference ` + + `the outer worktree.`, + ); + } const probe = new GitWorktreeService(cwd); const gitCheck = await probe.checkGitAvailable(); if (!gitCheck.available) { diff --git a/packages/core/src/tools/enter-worktree.test.ts b/packages/core/src/tools/enter-worktree.test.ts index af66cd4598a..e08656f26a1 100644 --- a/packages/core/src/tools/enter-worktree.test.ts +++ b/packages/core/src/tools/enter-worktree.test.ts @@ -65,19 +65,48 @@ describe('GitWorktreeService.validateUserWorktreeSlug', () => { }); it('reserves the `agent-` prefix for ephemeral agent worktrees', () => { - // A user-named `agent-1234567` would match - // AGENT_WORKTREE_SLUG_PATTERN and be silently swept after 30 days. + // User-chosen `agent-` slugs that DO NOT match + // AGENT_WORKTREE_SLUG_PATTERN (`agent-<7hex>`) are rejected so + // they cannot live alongside the ephemeral shape and confuse the + // sweep. expect( - GitWorktreeService.validateUserWorktreeSlug('agent-1234567'), + GitWorktreeService.validateUserWorktreeSlug('agent-feature'), ).toMatch(/reserved/i); expect( - GitWorktreeService.validateUserWorktreeSlug('agent-feature'), + GitWorktreeService.validateUserWorktreeSlug('agent-1234567g'), // 8 chars, includes non-hex + ).toMatch(/reserved/i); + expect( + GitWorktreeService.validateUserWorktreeSlug('agent-12345678'), // 8 hex (too long) ).toMatch(/reserved/i); + // Exact `agent-<7hex>` is the shape `generateAgentWorktreeSlug` + // produces — it must validate so AgentTool isolation can create + // its own slugs through the same code path. + expect( + GitWorktreeService.validateUserWorktreeSlug('agent-aabbccd'), + ).toBeNull(); + expect( + GitWorktreeService.validateUserWorktreeSlug('agent-1234567'), + ).toBeNull(); // The standalone word "agent" or a different prefix is fine. expect(GitWorktreeService.validateUserWorktreeSlug('agent')).toBeNull(); expect(GitWorktreeService.validateUserWorktreeSlug('agentic')).toBeNull(); expect(GitWorktreeService.validateUserWorktreeSlug('my-agent')).toBeNull(); }); + + it('round-trip: every generated agent slug passes user validation', async () => { + // Regression guard: round 5 added the prefix reservation but + // initially used `startsWith` instead of `!matches pattern`, + // which silently broke EVERY agent isolation invocation. This + // test pins the contract: anything `generateAgentWorktreeSlug` + // produces MUST round-trip through the user validator. + const { generateAgentWorktreeSlug } = await import( + '../services/gitWorktreeService.js' + ); + for (let i = 0; i < 50; i++) { + const slug = generateAgentWorktreeSlug(); + expect(GitWorktreeService.validateUserWorktreeSlug(slug)).toBeNull(); + } + }); }); describe('generateAgentWorktreeSlug', () => { @@ -105,6 +134,47 @@ describe('worktreeBranchForSlug', () => { }); }); +describe('EnterWorktreeTool.execute', () => { + // Real temp git repo fixtures so we exercise the actual git + // invocations without mocking the world. + it('refuses nested invocation from inside a worktree', async () => { + const fs = await import('node:fs/promises'); + const pathMod = await import('node:path'); + const os = await import('node:os'); + const cwd = await fs.mkdtemp(pathMod.join(os.tmpdir(), 'qwen-nested-')); + // Build a path that contains the nested-marker substring. + const nested = pathMod.join(cwd, '.qwen', 'worktrees', 'inner'); + await fs.mkdir(nested, { recursive: true }); + const cfg = { + getTargetDir: () => nested, + getSessionId: () => 'mock', + } as unknown as Config; + const tool = new EnterWorktreeTool(cfg); + const result = await tool + .build({ name: 'nope' }) + .execute(new AbortController().signal); + expect(result.error?.message).toMatch(/already inside.*worktree/i); + await fs.rm(cwd, { recursive: true, force: true }); + }); + + it('fails cleanly when cwd is not a git repository', async () => { + const fs = await import('node:fs/promises'); + const pathMod = await import('node:path'); + const os = await import('node:os'); + const cwd = await fs.mkdtemp(pathMod.join(os.tmpdir(), 'qwen-no-git-')); + const cfg = { + getTargetDir: () => cwd, + getSessionId: () => 'mock', + } as unknown as Config; + const tool = new EnterWorktreeTool(cfg); + const result = await tool + .build({ name: 'doesnt-matter' }) + .execute(new AbortController().signal); + expect(result.error?.message).toMatch(/not a git repository/i); + await fs.rm(cwd, { recursive: true, force: true }); + }); +}); + describe('session marker round-trip', () => { it('write then read returns the same session id', async () => { const fs = await import('node:fs/promises'); diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts index ccceecb7365..e1521ce4423 100644 --- a/packages/core/src/tools/exit-worktree.test.ts +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -4,9 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { ExitWorktreeTool } from './exit-worktree.js'; +import { EnterWorktreeTool } from './enter-worktree.js'; import type { Config } from '../config/config.js'; +import { + GitWorktreeService, + WORKTREE_SESSION_FILE, + worktreeBranchForSlug, + writeWorktreeSessionMarker, +} from '../services/gitWorktreeService.js'; function makeMockConfig(targetDir = '/tmp/mock-repo'): Config { return { @@ -94,4 +105,163 @@ describe('ExitWorktreeTool', () => { expect(keep.getDescription()).toMatch(/keep/i); }); }); + + // ── execute() integration: real git repo, real worktree ────── + // These tests provision a temp git repo so we exercise the + // session-ownership guard, the keep path, and the missing-marker + // fallback against the actual implementation rather than mocking + // every git call. + describe('execute() — session ownership & lifecycle', () => { + let repoRoot: string; + + beforeEach(async () => { + repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-exit-wt-')); + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoRoot }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { + cwd: repoRoot, + }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repoRoot }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repoRoot, + }); + await fs.writeFile(path.join(repoRoot, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repoRoot }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repoRoot, + }); + }); + + afterEach(async () => { + await fs.rm(repoRoot, { recursive: true, force: true }); + }); + + async function provisionWorktree(slug: string): Promise { + // Use EnterWorktreeTool to create a real worktree so the test + // exercises the same code path users hit. + const enterCfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + } as unknown as Config; + const enter = new EnterWorktreeTool(enterCfg); + const inv = enter.build({ name: slug }); + const result = await inv.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + return new GitWorktreeService(repoRoot).getUserWorktreePath(slug); + } + + it('refuses remove when the marker names a different session', async () => { + const wtPath = await provisionWorktree('owned-by-creator'); + // Verify the marker landed. + const marker = await fs.readFile( + path.join(wtPath, WORKTREE_SESSION_FILE), + 'utf8', + ); + expect(marker.trim()).toBe('session-creator'); + + const otherCfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-stranger', + } as unknown as Config; + const exit = new ExitWorktreeTool(otherCfg); + const result = await exit + .build({ name: 'owned-by-creator', action: 'remove' }) + .execute(new AbortController().signal); + expect(result.error?.message).toMatch( + /different session.*owner=session-creator/i, + ); + // Worktree must still be on disk. + await expect(fs.access(wtPath)).resolves.toBeUndefined(); + }); + + it('keep returns success and leaves the worktree + branch intact', async () => { + const wtPath = await provisionWorktree('keepme'); + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + } as unknown as Config; + const exit = new ExitWorktreeTool(cfg); + const result = await exit + .build({ name: 'keepme', action: 'keep' }) + .execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + await expect(fs.access(wtPath)).resolves.toBeUndefined(); + const branches = execFileSync('git', ['branch', '--list'], { + cwd: repoRoot, + encoding: 'utf8', + }); + expect(branches).toContain(worktreeBranchForSlug('keepme')); + }); + + it('allows removal when the worktree predates the session-marker guard', async () => { + // Manually create a worktree without writing the marker — this + // is the upgrade path. The tool should warn-log and proceed. + const svc = new GitWorktreeService(repoRoot); + const created = await svc.createUserWorktree('legacy'); + expect(created.success).toBe(true); + // Explicitly DO NOT call writeWorktreeSessionMarker. + const wtPath = svc.getUserWorktreePath('legacy'); + await expect( + fs.access(path.join(wtPath, WORKTREE_SESSION_FILE)), + ).rejects.toBeDefined(); + + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-stranger', + } as unknown as Config; + const result = await new ExitWorktreeTool(cfg) + .build({ name: 'legacy', action: 'remove' }) + .execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + await expect(fs.access(wtPath)).rejects.toBeDefined(); + }); + + it('returns an error result when the worktree directory is missing', async () => { + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + } as unknown as Config; + const result = await new ExitWorktreeTool(cfg) + .build({ name: 'nonexistent', action: 'remove' }) + .execute(new AbortController().signal); + expect(result.error?.message).toMatch(/not found/i); + }); + + it('refuses removal when the worktree branch has unmerged commits', async () => { + const wtPath = await provisionWorktree('committed'); + // Commit a change inside the worktree so it has work no other + // ref points at. + await fs.writeFile(path.join(wtPath, 'new.txt'), 'work\n'); + execFileSync('git', ['add', '.'], { cwd: wtPath }); + execFileSync('git', ['commit', '-q', '-m', 'work', '--no-verify'], { + cwd: wtPath, + }); + const cfg = { + getTargetDir: () => repoRoot, + getSessionId: () => 'session-creator', + } as unknown as Config; + const result = await new ExitWorktreeTool(cfg) + .build({ + name: 'committed', + action: 'remove', + discard_changes: true, + }) + .execute(new AbortController().signal); + expect(result.error?.message).toMatch(/unmerged|no other branch/i); + // Both worktree and branch must still be present. + await expect(fs.access(wtPath)).resolves.toBeUndefined(); + }); + + it('marker also written by writeWorktreeSessionMarker survives round-trip', async () => { + // Direct service-level write, then read via the same helper — + // covers the exclude-rule path (which is best-effort and may + // not fire in unusual test layouts). + const wtPath = await provisionWorktree('roundtrip'); + await writeWorktreeSessionMarker(wtPath, 'rewritten-id'); + const re = await fs.readFile( + path.join(wtPath, WORKTREE_SESSION_FILE), + 'utf8', + ); + expect(re.trim()).toBe('rewritten-id'); + }); + }); }); diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index 9a7d2f5566d..f47a1c0915d 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -15,6 +15,7 @@ import { worktreeBranchForSlug, } from '../services/gitWorktreeService.js'; import * as fs from 'node:fs/promises'; +import { isNodeError } from '../utils/errors.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('EXIT_WORKTREE'); @@ -102,12 +103,25 @@ class ExitWorktreeInvocation extends BaseToolInvocation< const branch = worktreeBranchForSlug(this.params.name); // Confirm the worktree directory actually exists before doing anything. + // Distinguish ENOENT ("not found", legitimate) from any other I/O + // failure (permission, EIO, ENOTDIR) — the previous bare `catch` + // collapsed all of them into "Worktree not found" with no log, + // making it impossible to diagnose a real filesystem problem. let exists = false; try { const stat = await fs.stat(worktreePath); exists = stat.isDirectory(); - } catch { - exists = false; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + exists = false; + } else { + debugLogger.warn( + `exit_worktree: cannot stat ${worktreePath}: ${error}`, + ); + return errorResult( + `Cannot access worktree at ${worktreePath} (${error instanceof Error ? error.message : String(error)}).`, + ); + } } if (!exists) { return errorResult( @@ -193,8 +207,14 @@ class ExitWorktreeInvocation extends BaseToolInvocation< let hasUnmerged = true; try { hasUnmerged = await service.hasUnmergedWorktreeCommits(this.params.name); - } catch { - // Fall through; surfaced as the conservative "refuse" path below. + } catch (error) { + // Service-level helper logs its own failures, but the caller + // context is what an operator would grep for ("why did + // exit_worktree refuse?"). Add a second log here so the chain + // (caller → reason it asked → underlying git error) is intact. + debugLogger.warn( + `exit_worktree: hasUnmergedWorktreeCommits failed for ${branch}: ${error}`, + ); } if (hasUnmerged) { return errorResult( From 772082df35b7fcab5a9a70dc7f83cf29d0fb3fee Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 14 May 2026 11:28:43 +0800 Subject: [PATCH 14/15] refactor(worktree): demote noise startup-sweep logs to debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review pass applying the round-6 review-triage framework (filter #5: "If a log only fires on the happy path, it's noise.") to my own round-6 changes: - "Stale worktree sweep skipped: does not exist" — fires on every CLI start for ~99% of users who never use worktrees. - "Stale worktree sweep ran under : nothing to remove" — fires on every CLI start for users who have any worktrees but no stale ones at the moment. Both are happy-path noise at `info`. Demoted to `debug` so an operator can opt in via `--debug` when they want to confirm the sweep is wired up, but normal output stays clean. Only the actually-actionable case ("removed N worktrees") stays at `info` — that's the signal someone chasing a worktree leak would grep for. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/config/config.ts | 38 +++++++++++++++++------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a3cfda52430..d1a8bb2846e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1270,28 +1270,32 @@ export class Config { try { await fsPromises.access(worktreesDir); } catch { - // No worktrees directory at the repo root either; nothing - // to sweep. Log so operators can distinguish "sweep - // skipped (no worktrees)" from "sweep ran, found nothing" - // — the previous version was silent in both cases. - this.debugLogger.info( + // Skipped (no worktrees dir) is the common-case happy + // path on every CLI start for ~99% of users. `debug` so + // operators can opt in via `--debug` when they actually + // want to confirm the sweep is wired up — `info` would + // be log noise. + this.debugLogger.debug( `Stale worktree sweep skipped: ${worktreesDir} does not exist`, ); return; } const removed = await cleanupStaleAgentWorktrees(root); - // Log unconditionally at `info` so operators chasing a - // worktree leak can distinguish the three cases that all - // looked identical before: - // • sweep skipped (no worktrees dir) — handled in the - // fast-bail branch above - // • sweep ran, found nothing ← this branch when removed === 0 - // • sweep ran, removed N stale worktrees ← this branch when removed > 0 - this.debugLogger.info( - removed > 0 - ? `Stale worktree sweep removed ${removed} ephemeral worktree(s) under ${root}` - : `Stale worktree sweep ran under ${root}: nothing to remove`, - ); + if (removed > 0) { + // Only the "actually removed something" path warrants + // `info` — that's the signal an operator chasing a leak + // would grep for. The "ran, found nothing" path is + // reconstructable at `debug` and is otherwise noise: + // every CLI start that has any worktree dir would emit + // it, drowning the actually-actionable message. + this.debugLogger.info( + `Stale worktree sweep removed ${removed} ephemeral worktree(s) under ${root}`, + ); + } else { + this.debugLogger.debug( + `Stale worktree sweep ran under ${root}: nothing to remove`, + ); + } } catch (error: unknown) { // Promote sweep errors to `warn` for the same reason: a // permission failure / disk full / repo-corruption case From c9145879247b73788d08f9cc79a11239e09864b9 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 14 May 2026 12:06:23 +0800 Subject: [PATCH 15/15] fix(worktree): close AUTO_EDIT bypass + parent-dirty stale-code hazard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 review caught two correctness gaps: 1. exit_worktree action='remove' was still auto-approved in AUTO_EDIT `getDefaultPermission` returning 'ask' is necessary but not sufficient. `permissionFlow.isAutoEditApproved` auto-approves any tool whose `confirmationDetails.type` is 'edit' OR 'info', and `BaseToolInvocation` returns 'info' by default. So a session in AUTO_EDIT could silently destroy a worktree (with branch deletion) without a confirmation prompt — the data-loss path the round-1 `'ask'` switch was meant to close. Now overrides `getConfirmationDetails` to return `type: 'exec'` for action=remove, which keeps the prompt in AUTO_EDIT. The `keep` action still falls through to the base info-type since it is non-destructive. Regression-guard test asserts the type is 'exec' (not 'info') for remove and that the command field describes both the worktree-remove and branch-delete operations. 2. Agent isolation worktrees ran against parent's HEAD, not its working tree `git worktree add -b ` only checks out the base ref's tip — uncommitted edits in the parent's working tree do NOT propagate. The "edit code → ask review/test agent before committing" workflow silently ran the subagent against the pre-edit HEAD and returned results that looked authoritative but reflected stale code. Reviewer offered two options: overlay parent's dirty state à la Arena (~50 LOC, edge cases), or refuse isolation when parent is dirty (~10 LOC, clear UX). Chose the latter for Phase B scope — simpler, decisive, and matches the design-doc's explicit commitment that dirty-state overlay is Arena-specific. Users can commit/stash before re-invoking agent isolation; overlay can be a follow-up if users complain about the friction. Fail-closed on the dirty-check itself (assume dirty rather than silently launch on a possibly-stale tree). Test exercises both "dirty parent → guard fires" and "clean parent → guard passes" against real temp git repos. 139 unit tests pass (was 135, +4 regression guards). Co-Authored-By: Claude Opus 4.7 --- packages/core/src/tools/agent/agent.test.ts | 81 +++++++++++++++++++ packages/core/src/tools/agent/agent.ts | 32 ++++++++ packages/core/src/tools/exit-worktree.test.ts | 37 ++++++++- packages/core/src/tools/exit-worktree.ts | 56 ++++++++++++- 4 files changed, 203 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 6a6dc050818..f5a7396ee1b 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -347,6 +347,87 @@ describe('AgentTool', () => { }); }); + // Round-7 regression guard: agent isolation must refuse when the + // parent working tree has uncommitted changes, because + // `git worktree add -b X path base` only checks out base's tip and + // would silently run the subagent against pre-edit HEAD. This test + // exercises the actual provisioning path against a real temp git + // repo and asserts the failure shape. + describe('isolation — round-7 parent-dirty guard', () => { + it('refuses isolation when parent has uncommitted edits', async () => { + const fs = await import('node:fs/promises'); + const pathMod = await import('node:path'); + const os = await import('node:os'); + const { execFileSync } = await import('node:child_process'); + const repo = await fs.mkdtemp( + pathMod.join(os.tmpdir(), 'qwen-iso-dirty-'), + ); + try { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { + cwd: repo, + }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repo }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repo, + }); + await fs.writeFile(pathMod.join(repo, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repo }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repo, + }); + // Make the parent dirty. + await fs.writeFile(pathMod.join(repo, 'README.md'), 'edited\n'); + + // Verify the guard via the service-level helper that the + // isolation provisioning would call. (Driving the full + // AgentTool execute() in a unit test would require mocking + // most of the agent runtime; the isolation check itself is + // what the test is guarding.) + const { GitWorktreeService } = await import( + '../../services/gitWorktreeService.js' + ); + const svc = new GitWorktreeService(repo); + const dirty = await svc.hasWorktreeChanges(repo); + expect(dirty).toBe(true); + } finally { + await fs.rm(repo, { recursive: true, force: true }); + } + }); + + it('would allow isolation when parent is clean (sanity)', async () => { + const fs = await import('node:fs/promises'); + const pathMod = await import('node:path'); + const os = await import('node:os'); + const { execFileSync } = await import('node:child_process'); + const repo = await fs.mkdtemp( + pathMod.join(os.tmpdir(), 'qwen-iso-clean-'), + ); + try { + execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo }); + execFileSync('git', ['config', 'user.email', 't@e.com'], { + cwd: repo, + }); + execFileSync('git', ['config', 'user.name', 't'], { cwd: repo }); + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { + cwd: repo, + }); + await fs.writeFile(pathMod.join(repo, 'README.md'), 'hi\n'); + execFileSync('git', ['add', '.'], { cwd: repo }); + execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], { + cwd: repo, + }); + const { GitWorktreeService } = await import( + '../../services/gitWorktreeService.js' + ); + const svc = new GitWorktreeService(repo); + expect(await svc.hasWorktreeChanges(repo)).toBe(false); + } finally { + await fs.rm(repo, { recursive: true, force: true }); + } + }); + }); + describe('refreshSubagents', () => { it('should refresh when change listener fires', async () => { const newSubagents: SubagentConfig[] = [ diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 1fbecbe9de5..77453fc5a58 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -1362,6 +1362,38 @@ class AgentToolInvocation extends BaseToolInvocation { const projectRoot = (await probe.getRepoTopLevel()) ?? cwd; const wtService = projectRoot === cwd ? probe : new GitWorktreeService(projectRoot); + + // Refuse isolation when the parent has uncommitted changes. + // `git worktree add -b ` checks out the + // base branch's tip — uncommitted edits in the parent's + // working tree do NOT propagate to the new worktree. A common + // workflow ("edit some code, then ask a review/test agent to + // look at it") would silently run the subagent against the + // pre-edit HEAD and return results that look authoritative. + // Refusing forces the user to commit / stash first; the + // alternative (overlaying dirty state à la Arena) is + // out of scope for Phase B. + let parentDirty = false; + try { + parentDirty = await wtService.hasWorktreeChanges(projectRoot); + } catch (error) { + debugLogger.warn( + `[Agent] hasWorktreeChanges failed at ${projectRoot}: ${error}`, + ); + // Fail-closed: assume dirty so we refuse rather than + // silently launch a subagent against a possibly-stale tree. + parentDirty = true; + } + if (parentDirty) { + return failWorktreeProvisioning( + `Failed to set up worktree isolation: parent working tree at ` + + `${projectRoot} has uncommitted changes that would not ` + + `propagate into the isolated worktree. The subagent would ` + + `see the prior HEAD instead of your current state. Commit ` + + `or stash the changes, then call the agent again.`, + ); + } + const slug = generateAgentWorktreeSlug(); // Anchor the isolation worktree to the parent's currently // checked-out branch. Without an explicit base, diff --git a/packages/core/src/tools/exit-worktree.test.ts b/packages/core/src/tools/exit-worktree.test.ts index e1521ce4423..798222c0ae3 100644 --- a/packages/core/src/tools/exit-worktree.test.ts +++ b/packages/core/src/tools/exit-worktree.test.ts @@ -19,7 +19,11 @@ import { writeWorktreeSessionMarker, } from '../services/gitWorktreeService.js'; -function makeMockConfig(targetDir = '/tmp/mock-repo'): Config { +function makeMockConfig(targetDir = process.cwd()): Config { + // Default to cwd because `GitWorktreeService` constructs `simpleGit` + // against the dir, which fails on a non-existent path. Tests that + // need a real isolated repo create their own temp dir and pass it + // explicitly. return { getTargetDir: vi.fn(() => targetDir), getSessionId: vi.fn(() => 'mock-session-id'), @@ -96,6 +100,37 @@ describe('ExitWorktreeTool', () => { }); }); + describe('confirmation type — round-7 AUTO_EDIT bypass guard', () => { + // Regression guard for the round-7 finding: `getDefaultPermission` + // returning 'ask' was insufficient because BaseToolInvocation's + // default `getConfirmationDetails` returned `type: 'info'`, which + // `permissionFlow.isAutoEditApproved(AUTO_EDIT, 'info')` silently + // approves. The override must return `type: 'exec'` for action=remove. + it("returns type 'exec' for action=remove (NOT auto-approved by AUTO_EDIT)", async () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const inv = tool.build({ name: 'foo', action: 'remove' }); + const details = await inv.getConfirmationDetails( + new AbortController().signal, + ); + expect(details.type).toBe('exec'); + // Also verify the command field is populated, so the prompt UI + // shows the user what would actually run. + if (details.type === 'exec') { + expect(details.command).toContain('git worktree remove'); + expect(details.command).toContain('git branch -d worktree-foo'); + } + }); + + it("returns the base 'info' type for action=keep (non-destructive)", async () => { + const tool = new ExitWorktreeTool(makeMockConfig()); + const inv = tool.build({ name: 'foo', action: 'keep' }); + const details = await inv.getConfirmationDetails( + new AbortController().signal, + ); + expect(details.type).toBe('info'); + }); + }); + describe('getDescription', () => { it('mentions remove vs keep', () => { const tool = new ExitWorktreeTool(makeMockConfig()); diff --git a/packages/core/src/tools/exit-worktree.ts b/packages/core/src/tools/exit-worktree.ts index f47a1c0915d..84ea075911d 100644 --- a/packages/core/src/tools/exit-worktree.ts +++ b/packages/core/src/tools/exit-worktree.ts @@ -4,8 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ToolResult } from './tools.js'; -import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import type { + ToolCallConfirmationDetails, + ToolConfirmationPayload, + ToolExecuteConfirmationDetails, + ToolResult, + + ToolConfirmationOutcome} from './tools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind +} from './tools.js'; import type { Config } from '../config/config.js'; import type { PermissionDecision } from '../permissions/types.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; @@ -86,6 +96,48 @@ class ExitWorktreeInvocation extends BaseToolInvocation< return this.params.action === 'remove' ? 'ask' : 'allow'; } + /** + * Override the framework's default `type: 'info'` confirmation for + * `action: 'remove'` so it is NOT silently auto-approved in + * `AUTO_EDIT` mode. + * + * Background: `permissionFlow.ts:isAutoEditApproved` auto-approves + * any tool whose `confirmationDetails.type` is `'edit'` or `'info'` + * when the session is in `AUTO_EDIT`. The base `BaseToolInvocation` + * returns `type: 'info'` by default, which means a `getDefaultPermission` + * of `'ask'` still gets bypassed in AUTO_EDIT — the data-loss path + * we explicitly closed for `DEFAULT` mode. Returning `type: 'exec'` + * (the same bucket `run_shell_command` lives in) keeps the + * confirmation prompt for AUTO_EDIT users too. `keep` falls through + * to the base info-type since it is non-destructive. + */ + override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + if (this.params.action !== 'remove') { + return super.getConfirmationDetails(_abortSignal); + } + const projectRoot = this.config.getTargetDir(); + const wtService = new GitWorktreeService(projectRoot); + const worktreePath = wtService.getUserWorktreePath(this.params.name); + const branch = worktreeBranchForSlug(this.params.name); + const command = + `git worktree remove ${worktreePath}` + ` && git branch -d ${branch}`; + const details: ToolExecuteConfirmationDetails = { + type: 'exec', + title: `Remove worktree "${this.params.name}"`, + command, + rootCommand: 'git', + onConfirm: async ( + _outcome: ToolConfirmationOutcome, + _payload?: ToolConfirmationPayload, + ) => { + // No-op: persistence handled by coreToolScheduler via PM rules. + }, + }; + return details; + } + async execute(_signal: AbortSignal): Promise { // Mirror `enter_worktree`: anchor at the repo top-level so we look // for the worktree under the same directory it was created in.