fix(tasks): default task repo working directory

eyrie · ee25287 · 2026-06-16 · 自包含,读完即弃

1 commit
9 文件
+98 / −47
35% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

1TL;DR

把任务创建的 repo 选择从「全选多 repo」改成「单选一个 repo」,并在守护进程侧用那个 repo 的 gitCommonDir(去尾 /.git)自动推出默认工作目录写到 task_repos.workingDir

动机:agent session 的创建(AgentService.createSession)要求任务已有工作目录——否则直接抛 noWorkingDir。之前创建任务时不存 workingDir(除非调用方显式传),用户必须额外走一步才能启动 agent loop。这条 PR 把「推断默认值」塞进任务创建的写路径,消灭了那一步额外操作。

2旅程:任务创建与默认工作目录

用户新建任务时,桌面端只允许绑一个 repo(首个自动选中),守护进程接管 working directory 的默认值推导:从 repo 的 git common dir 去掉 /.git 后缀即得。

全景 · 涉及 4 个文件
选 repo
task-draft-model.ts
提交 推断默认工作目录
repos.ts
存 task_repos 行
repos.ts
01 / Desktop (UI) 02 / Daemon (Service) Open Dialog 1st repo checked Submit create + attach repo Lookup Repo gitCommonDir Default WD strip /.git → validate inferred create task attachTaskRepo Legend User UI Agent logic Policy Tool action Context / trace

2.1桌面端:选 repo 从多选变单选

以前:打开创建面板时所有已挂载的 repo 全选,用户可以取消勾选任意组合,tasks.create 时传入 projectRepoIds 数组。守护进程拿到这些 id 后建 task_repos 行,但 workingDir 字段始终为 null——除非调用方通过 attachTaskRepo 显式传了 workingDir(桌面端从未传)。

现在:打开面板时只选中第一个 repo,toggle 行为从「翻转当前行」变成「选中当前行、取消其他行」——语义上就是 radio 而非 checkbox。提交时 projectRepoIds 最多一个元素。单选约束是为下游 agent session 准备一个无歧义的 cwd——session 的 getTaskWorkingDir 只认单一 working directory,多 repo 无主次时不知道该取哪个。

以前
所有 repo 默认 checked
toggle: 只翻转当前行
selectedProjectRepoIds 返回多个 id
现在
第一个 repo checked,其余 unchecked
toggle: 选中当前行,取消其他所有行
selectedProjectRepoIds 最多一个 id

toggle 的核心差异在 CreateTaskDialogUnsavedTaskPanel 两个组件里,代码相同:

apps/desktop/src/renderer/features/tasks/components/UnsavedTaskPanel.tsx
function toggleRepo(projectRepoId: string, selected: boolean) {
  setDraft((current) => ({
    ...current,
    repos: current.repos.map((repo) => ({
      ...repo,
      selected: repo.projectRepoId === projectRepoId ? selected : false,
      // 关键:匹配的行保留 selected,其余全部置 false——radio 语义
    })),
  }))
}

以前这行是 repo.projectRepoId === projectRepoId ? { ...repo, selected } : repo——只动当前行、不动其他行,每行独立 toggle,多选语义。

草稿模型的初始化也同步改了:

apps/desktop/src/renderer/features/tasks/task-draft-model.ts
export function createTaskDraft(projectRepos: readonly ProjectRepoDto[]): TaskDraft {
  return {
    title: '',
    description: '',
    priority: null,
    repos: projectRepos.map((relation, index) => ({
      projectRepoId: relation.id,
      name: relation.repo.name,
      selected: index === 0,  // 只勾第一个,以前是写死的 true
    })),
    labels: [],
  }
}
apps/desktop/src/renderer/features/tasks/task-draft-model.ts
export function selectedProjectRepoIds(draft: TaskDraft): string[] {
  const selected = selectedRepo(draft)
  return selected ? [selected.projectRepoId] : []
  // 以前:filter + map 可能返回多个 id
}

function selectedRepo(draft: TaskDraft): DraftRepoSelection | undefined {
  return draft.repos.find((repo) => repo.selected)
  // 新增辅助——语义上「至多一个」,用 find 而非 filter 强调这一点
}

2.2守护进程:从 gitCommonDir 推断默认工作目录

桌面端选 repo 只是拿到了 projectRepoId——repo 的工作目录在哪、能不能访达,是守护进程的职责。本 PR 加了两件事:推断默认值、解引用验证。

apps/daemon/src/services/repos.ts
function defaultWorkingDirFromGitCommonDir(gitCommonDir: string): string | null {
  if (gitCommonDir.endsWith('/.git') || gitCommonDir.endsWith('\\.git')) {
    return gitCommonDir.slice(0, -5)    // 去掉末尾 /.git 就是 repo 根目录
  }
  return null                            // 裸仓库或非标准目录不自动推断
}

RepoService.attachTaskRepo 里,这个默认值被用作 workingDir 的 fallback:

apps/daemon/src/services/repos.ts
const repo = this.getActiveRow(projectRepo.repoId)
const defaultWorkingDir = defaultWorkingDirFromGitCommonDir(repo.gitCommonDir)
const selectedWorkingDir = workingDir ?? defaultWorkingDir    // 调用方传了就用的,否则用推断值
const resolvedWorkingDir = selectedWorkingDir
  ? resolveWithinRootsSync(
      assertAbsoluteWorkingDir(selectedWorkingDir),
      this.filesystemRoots,
      this.homeDir,
    )
  : null
// 之后 resolvedWorkingDir 写入 task_repos.workingDir

调用方(桌面端 tRPC caller、或将来 agent 会话)仍然可以显式传 workingDir 来覆盖。不传时,守护进程按 gitCommonDir → 去 /.git → 验证在 roots 内 → 写库的链路自己走完。

这条链路的下游消费者是 AgentService.createSession:创建 agent session 时调 repo.getTaskWorkingDir(taskId),拿到 cwd 后写入 agent_sessions.cwd。没有 cwd 则直接拒绝创建——换句话说,本 PR 就是让「创建任务」这一步自动把 agent session 需要的前提条件配齐。

从创建任务到启动 agent session loop 的最小路径:① 创建任务时选一个 repo(面板自动选首个)→ ② 守护进程推断默认 workingDir → ③ AgentService.createSessiongetTaskWorkingDir 拿到 cwd → ④ 创建 agent_sessions 行,进入 idle → ⑤ startTurn 开始 loop。第②步在此 PR 之前是缺失的,需要用户手动补。

2.3排查路标

排查路标 · 任务创建与默认工作目录
症状从哪下手
任务创建后面板一个 repo 都没选中task-draft-model.ts:看 createTaskDraftselected: index === 0 是否被执行,以及 projectRepos 数组是否为空
任务创建后 task_repos.workingDir 为 nullrepos.ts:看 gitCommonDir 的值——是否不以 /.git 结尾(裸仓库)
工作目录不在允许的文件系统根路径内repos.tsresolveWithinRootsSync 逻辑——验证 filesystemRoots 配置和实际路径
覆盖的工作目录不生效调用方的 tRPC 参数:createTaskWithRelationsattachTaskRepo 是否传了 workingDir;守护进程侧 ?? defaultWorkingDir 是否被显式调用覆盖

3心智模型补丁

一个任务可以绑定多个 repo,没有主 repo / 工作目录的概念。 一个任务至多绑定一个 repo,守护进程自动从 repo 的 git 根目录推断默认工作目录,写到 task_repos.workingDir
AgentService.createSession 在创建 agent session 时要求任务已有单一工作目录——无 cwd 则抛 noWorkingDir。推断默认值消除了「创建任务后还要再操作一次才能启动 agent loop」的摩擦。
桌面端创建面板中 repo 列表是全选 checkbox,用户可勾任意组合。 repo 列表是 radio 单选,首项自动选中,点击另一项取消前一项。取消当前项创建不绑定 repo 的任务(合法结果)。
创建任务只传 projectRepoIds,工作目录需要后续以 attachTaskRepo 单独设。 tasks.create 时守护进程已自动推断并写入工作目录;workingDir 参数仍然可用作显式覆盖。

4测试与风险地图

有兜底的薄冰
默认工作目录推断workflow.test.ts 中新测试验证 attachTaskRepo 不传 workingDir 时自动使用 gitRepo.path.git 的父目录) 🟡 defaultWorkingDirFromGitCommonDir 在 Windows 路径下 \\.git 后缀的匹配:测试跑在 macOS/Linux CI 上,Windows 反斜杠路径的末尾 5 字符是否真的是 \\.git(而非如 \.git)未验证。代码用 endsWith('\\.git') 处理了双反斜杠,但实际 Windows 路径可能只有单反斜杠。
原子创建含默认工作目录:已有 tasks.create 原子创建测试新增断言——创建后 taskRepos.list 返回的 workingDirgitRepo.path 一致 🟡 gitCommonDir 不以 /.git 结尾时返回 null——这种情况下 workingDir 仍是 null,与旧行为一致。是设计如此还是遗漏?注释未说明。
桌面端单选逻辑task-draft-model.test.ts 验证了 createTaskDraft 只选第一个、selectedProjectRepoIds 至多返回一个 id 🟡 handleSubscribedChange / selectedProjectRepoIds 改为返回单元素数组,但 CreateTaskWithRelationsInput.projectRepoIds 的类型仍是 readonly string[](而非 [string] | []string | null)。数组长度约束全靠约定和 UI 的 radio 行为——UI 之外没有人阻止传多个 id。
🟢 移除多 repo 能力后,旧数据中已绑定多个 repo 的任务不受影响(删除/查询仍按原逻辑工作),只是无法通过面板再创建新的多 repo 任务。
🟡 合并前注意:Windows 路径的 \\.git 检测逻辑(defaultWorkingDirFromGitCommonDirendsWith('\\.git'))在 macOS CI 上无法覆盖——要么加一条 Windows 已转义的注释说明意图,要么在实际 Windows 环境里跑一遍。