fix(tasks): default task repo working directory
eyrie · ee25287 · 2026-06-16 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
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 后缀即得。
task-draft-model.ts→ 提交→ 推断默认工作目录
repos.ts→ 存 task_repos 行
repos.ts
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 无主次时不知道该取哪个。
toggle 的核心差异在 CreateTaskDialog 和 UnsavedTaskPanel 两个组件里,代码相同:
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,多选语义。
草稿模型的初始化也同步改了:
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: [],
}
}
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 加了两件事:推断默认值、解引用验证。
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:
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 需要的前提条件配齐。
AgentService.createSession 读 getTaskWorkingDir 拿到 cwd → ④ 创建 agent_sessions 行,进入 idle → ⑤ startTurn 开始 loop。第②步在此 PR 之前是缺失的,需要用户手动补。
2.3排查路标
排查路标 · 任务创建与默认工作目录
| 症状 | 从哪下手 |
|---|---|
| 任务创建后面板一个 repo 都没选中 | task-draft-model.ts:看 createTaskDraft 的 selected: index === 0 是否被执行,以及 projectRepos 数组是否为空 |
任务创建后 task_repos.workingDir 为 null | repos.ts:看 gitCommonDir 的值——是否不以 /.git 结尾(裸仓库) |
| 工作目录不在允许的文件系统根路径内 | repos.ts:resolveWithinRootsSync 逻辑——验证 filesystemRoots 配置和实际路径 |
| 覆盖的工作目录不生效 | 调用方的 tRPC 参数:createTaskWithRelations 或 attachTaskRepo 是否传了 workingDir;守护进程侧 ?? defaultWorkingDir 是否被显式调用覆盖 |
3心智模型补丁
task_repos.workingDir。
AgentService.createSession 在创建 agent session 时要求任务已有单一工作目录——无 cwd 则抛 noWorkingDir。推断默认值消除了「创建任务后还要再操作一次才能启动 agent loop」的摩擦。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 返回的 workingDir 与 gitRepo.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 任务。 |
\\.git 检测逻辑(defaultWorkingDirFromGitCommonDir 中 endsWith('\\.git'))在 macOS CI 上无法覆盖——要么加一条 Windows 已转义的注释说明意图,要么在实际 Windows 环境里跑一遍。