PR #146 · task-template lifecycle:把「模板」做成一等公民

buffin · main...agent/task-template-lifecycleab3f6a22,未 push)· 2026-07-18 · 自包含,读完即弃

7 commits
107 文件
+4304 / −3495
44% 是测试代码
类型:功能(破坏性)

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

1TL;DR

这个 PR 把「Task Template」从 PR #143 落下的底层能力,补成完整、前后一致的用户功能。核心是三件事:(1) 模板定义变不可变——创建后名称/阶段/顺序/颜色/key 都不能再改,删掉了服务端全部 template.update / stage 增删改重排 API 与 CLI 命令;(2) 引入 Active / Archived / Deleted 三态生命周期——归档是可恢复、无级联副作用的「下架分配资格」,物理删除只对没有任何任务引用的归档模板开放;(3) 任务跨模板迁移成为独立显式领域操作 changeTemplate,不再借用通用 update 或看板拖拽。

配套地:GUI 创建弹窗新增独立的 Template + Stage 两个字段,Settings 新增项目级模板管理页,Project 默认模板成为服务端真相源(三端共享一套默认规则),删除了第二套不可达的 Task 创建路径(UnsavedTaskPanel),stage 颜色从任意 CSS 字符串收紧为受控枚举。动机来自随附设计文档 task-template-mvp-design.md 的一句判断:根因不是少了几个控件,而是「可读取 / 可分配 / 可运行」三个概念此前被一个含糊的 active 布尔混为一谈。

2变更地图(称重)

改动横跨全栈,但重心明确落在两处:daemon 领域实现(生命周期守卫 + 三种查找 + delta 推送)和 desktop 渲染层(管理页 + 创建/迁移交互)。两者合计约占非测试改动的三分之二。

apps/daemon
~2878 行 · 领域核心
apps/desktop
~2748 行 · 渲染交互
apps/cli
~1125 行 · 命令收敛
packages/api
~604 行 · 契约真相源
scripts
~172 行 · 搭车门禁
packages/client
~127 行 · dispatcher
packages/db
~107 行 · schema baseline
子系统设计重心(要细读)可放心略过
apps/daemonservices/templates.ts 生命周期守卫、template-definition.ts 派生器、tasks.tsresolveCreateStage/changeTemplatetrpc/services.ts 的 delta 接线、maintenance-tick.ts 的 reaper 钩子seed/createTask 调用补 templateId(dev-recovery、testing、board-seed 等机械跟随)
apps/desktopProjectTemplatesSettings.tsx(新,492 行)、CreateTaskDialog.tsxTaskTemplatePicker.tsx(新)、create-task-assignment.ts(新纯函数)、board.tsx 对账 effect、client-provider.tsx 订阅并集9 个测试 fixture 适配(stage 删 version、颜色改 token、模板补 archivedAt/canDelete);i18n 三语 ×28 key;UnsavedTaskPanel 删除(逻辑已搬入 CreateTaskDialog
apps/clitask change-template 新命令、template archive/restore--stages 收窄为 {name,color}删掉的旧命令测试(证明命令确实没了);本地 DTO 类型声明外迁到 @buffin/api
packages/*api:新 schema/错误码/DTO 字段;client:invalidateBoardMeta;db:archived_at + 去 partial 索引migrations/meta/* drizzle-kit 重生成物;_journal.json 仅时间戳
scripts / CIcheck-retired-name.mjs 是与本特性无关的搭车提交(d3b9479f);4 个 check-*.mjsexistsSync 过滤

诚实称重:107 个文件里真正承载设计的是 daemon 的生命周期/查找/delta 三块、desktop 的管理页与创建/迁移交互、以及 api 契约的形状收窄;其余约一半是「跟随 API 形状变化」的机械改动和删除。测试占 44%,其中 daemon 契约测试与 desktop 组件测试是新逻辑的主要兜底。

3架构一图流

两处架构面变了。其一,模板的写面从「一堆可变 CRUD + stage 子路由」坍缩成「一次性创建 + 三个生命周期开关」。其二,看板 delta 多了一条带外补发的通道:维护线程(reaper)物理删任务行后,经一个新钩子把「模板现在可以删了」这个变化推给客户端——这是以前完全不存在的一条边。

以前 · 可变 CRUD,delta 只从事务里发

client
update / reorder
templates.update
client
stage CRUD
templates.stages.*
reaper
物理删任务(不发任何 delta)
db

现在 · 不可变定义 + 三态开关 + reaper 补发

client
create / archive / restore / delete
TemplateService
TemplateService
flagged delta(boardMetaChanged)
client 缓存
reaper
onTasksReaped → 补发 flagged delta
client 缓存

右栏那条 reaper → onTasksReaped 的实线是本 PR 最不显眼、也最容易漏读的一笔:模板的「可删」资格只有在任务行被物理删除后才翻转,而物理删除发生在维护线程里,那里以前没有任何发布缝隙。旅程 D 专门走这条边。

4数据与状态先行

先只看形状,不讲行为——给后面的旅程预载词汇。契约真相源在 packages/api,数据库 baseline 在 packages/db

阶段颜色:从任意字符串收紧为受控枚举

以前 stage 颜色是 daemon 发来的任意 CSS 字符串,renderer 内联渲染;现在是六值枚举 token,DB 端也用 enum + CHECK 兜底。

packages/api/src/schemas.ts真实代码(节选)
/** Theme-aware color choices accepted for an immutable template stage. */
export const stageColorSchema = z.enum(['gray', 'blue', 'green', 'amber', 'red', 'pink'])

// 一个模板定义一次写死,公共输入里没有 position 或任何生命周期字段——客户端创建后无从再改
const templateStageSeedSchema = z.object({
  name: z.string().trim().min(1).max(120),
  color: stageColorSchema,        // 只收 name + color;key / band / position 全部服务端派生
})

export const MAX_TEMPLATE_STAGES = 20   // 单一数字:daemon validator 与编辑器的「加 stage」按钮共用

export const createTemplateSchema = templateFieldsSchema.extend({
  stages: z.array(templateStageSeedSchema).min(2).max(MAX_TEMPLATE_STAGES),  // 至少 todo 开 + done 闭
})

模板 DTO:新增 archivedAt 与 canDelete,stage 去掉 version

模板 DTO 多了两个字段表达生命周期;stage DTO 反而去掉version——因为 stage 不可变,永远不会有第二个版本。

packages/api/src/dto.ts真实代码(节选)
export type TemplateStageDto = {
  id: string; templateId: string; key: string; name: string
  color: StageColor   // 收窄枚举;且不再有 version / updatedAt(stage 不可变)
  position: number
  band: StageBand     // 语义带:todo / doing / done,绑定自动化,不绑名字或 key
}

export type TaskTemplateDto = {
  id: string; projectId: string; name: string; position: number
  version: number     // 乐观并发版本(archive/restore/delete 走它)
  archivedAt: string | null   // 生命周期标记;归档后仍可读,但不能接新分配
  canDelete: boolean          // 服务端派生:该归档定义已无任何任务引用 → 可物理删
  stages: TemplateStageDto[]
}

两条新的任务契约:显式 templateId + 跨模板迁移

packages/api/src/schemas.ts真实代码(节选)
export const createTaskSchema = z.object({
    projectId: idSchema,
    // 两个都省略 = 走项目默认;显式分配则两个身份都带,stage 永不隐式代表模板意图
    templateId: idSchema.optional(),
    stageId: idSchema.optional(),
    /* ...其余字段... */
  }).superRefine((input, ctx) => {
    if (input.stageId !== undefined && input.templateId === undefined) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['templateId'],
        message: 'templateId is required when stageId is provided' })  // 拒绝 stage-only 意图
    }
  })

/** 显式跨模板迁移,必须选定目标 stage */
export const changeTaskTemplateSchema = expectedVersionSchema.extend({
  targetTemplateId: idSchema,
  targetStageId: idSchema,
})

updateTaskSchema 同时删掉了 stageId——通用 update 不再能换列,同模板换列走 move,跨模板走 changeTemplate

错误码:新增三态生命周期码,删掉 stage 可变码

新增含义(白话)
template.archived目标模板已归档,不能作为分配/迁移/默认目标
template.notArchived只有归档模板才能 restore,未归档时报它
template.mustArchive物理删除前必须先归档(先 archive 再 delete 的两步闸门)
task.templateUnchanged迁移目标就是任务当前模板——白干,让你改用同模板 move

同步删除的旧码:stage.keyConflict / stage.inUse / stage.bandInvariant(stage 不再可单独增删改,这些冲突不可能发生)。三个新 template 码与 task.templateUnchanged 均映射 HTTP 409。

数据库 baseline:archived_at 取代 deleted_at,索引去 partial

pre-1.0 直接改 0000_init baseline,无兼容迁移。关键变化:task_templates(project_id, name) 唯一索引现在覆盖 active + archived 全部行(以前是 WHERE deleted_at IS NULL 的 partial)——即归档模板的名字仍占位,不能被新模板复用,只有物理删除后名称才释放。

packages/db/src/index.ts真实代码(节选)
export const taskTemplatesTable = sqliteTable('task_templates', {
    /* ...id / projectId / name / position / version / createdAt / updatedAt... */
    archivedAt: integer('archived_at'),   // 取代旧 deleted_at:可逆的「停放」轴
  },
  (table) => ({
    // 唯一索引不再带 partial predicate——名字在 active+archived 范围内共同唯一
    projectNameUnique: uniqueIndex('task_templates_project_name_unique').on(table.projectId, table.name),
    positionIdx: index('task_templates_position_idx').on(table.projectId, table.position, table.id),
  }),
)

template_stages 同步删掉 version / updated_at / deleted_at 三列(不可变行不该暗示并不存在的修改能力),color 从自由文本收紧为 enum + CHECK

5底座:三种查找 · delta 推送 · 乐观并发

三条旅程共用两套机制,先在这里走通一遍,旅程里只讲各自特有的部分。

5.1三种 stage 查找语义

设计文档的根因判断落到代码上,就是把以前那个含糊的 getActiveStageContext 拆成三种语义不同的查找。哪一个用错了,报出来的错误码就会指向用户根本没选的东西。

为什么这个分离重要,看创建任务时的落地:所选模板的「可分配性」被 assignable 查找定死;stage 随后用 operational(不带生命周期)查找,因此一个「属于归档兄弟模板的 stageId」报的是归属错误(跨模板),而不是那个用户根本没选中的兄弟模板的归档状态。

apps/daemon/src/services/tasks.ts · resolveCreateStage真实代码(节选)
private resolveCreateStage(input, defaultTemplateId) {
  if (input.stageId !== undefined && input.templateId === undefined)
    throw new AppError({ code: BuffinErrorCode.validation.failed })   // 拒绝 stage-only
  const templateId = input.templateId ?? defaultTemplateId            // 缺省落到项目默认
  const template = this.templates.getAssignableTemplateRow(templateId)  // ① 目标先定可分配性
  if (template.projectId !== input.projectId)
    throw new AppError({ code: BuffinErrorCode.task.stageCrossProject })
  if (input.stageId === undefined) {
    const stage = this.templates.firstAssignableStage(template.id)     // 无 stage:落该模板 todo 开列
    /* ... */
  }
  // stage 用 operational 查找:模板可分配性已定,stage 归属不符时报 cross-*,
  // 而不是未被选中的兄弟模板的归档状态
  const context = this.templates.getOperationalStageContext(input.stageId)   // ② stage 只看归属
  if (context.projectId !== input.projectId)
    throw new AppError({ code: BuffinErrorCode.task.stageCrossProject })
  if (context.template.id !== template.id)
    throw new AppError({ code: BuffinErrorCode.task.stageCrossTemplate })
  return context
}

5.2delta 推送与 boardMetaChanged 边沿标志

看板 delta 一直只有一种形态:整份 board 快照。本 PR 给它加了一个边沿触发的旗标 boardMetaChanged——它请求客户端「顺带失效两个不订阅 delta 的缓存:模板列表 + 项目详情」。普通任务写(拖拽/建/改)不带旗;模板生命周期写、默认模板变更、子树硬删都带旗。

服务端只有一个发布器 createBoardSnapshotPublisher,被传输层 mutation 和维护线程 reaper 共用——目的是让每一次推送都过同一个订阅者守卫(无人订阅就不投影,省掉最贵的一步)和同一条投影路径

apps/daemon/src/trpc/services.ts · createBoardSnapshotPublisher真实代码(节选)
export function createBoardSnapshotPublisher(deps) {
  return (projectId, options) => {
    // 投影整份 board 是发布里最贵的一步;零订阅者时无人会听,而 topic 首次订阅本就以 resync
    // 标记开场、会重取快照——所以这里直接短路
    if (!deps.boardDeltas.hasSubscribers(projectId)) return
    try {
      const board = deps.tasks.board(projectId)
      deps.boardDeltas.publish(projectId, {
        kind: 'snapshot', projectId, board,
        ...(options?.boardMetaChanged ? { boardMetaChanged: true as const } : {}),  // 旗标
      })
    } catch (err) {
      // 写已提交;缓存修复不能回滚这次 mutation
      logger.warn({ err, projectId }, 'board delta publish failed')
    }
  }
}

客户端这一侧,dispatcher 收到带旗的 delta 时,除了打 board 缓存,还会跑 invalidateBoardMeta——本 PR 把它从「只失效模板列表」扩成「模板列表 + 项目详情」,因为默认模板变更也走同一条旗标,而 defaultTemplateId 挂在项目详情上、驱动隐式建任务。

packages/client/src/dispatcher.ts · invalidateBoardMeta真实代码(节选)
// 修复 boardMetaChanged 这条边沿信号覆盖的每个缓存:模板列表(看板列内嵌 stages)
// 与项目详情(默认模板变更发同一条 delta,详情的 defaultTemplateId 掌舵隐式建任务)。
// 两者都不自己订阅 delta,这条旗标是它俩唯一的推送通道。
function invalidateBoardMeta(queryClient, projectId) {
  return [
    queryClient.invalidateQueries({ queryKey: templatesListQueryKey(projectId) }),
    queryClient.invalidateQueries({ queryKey: projectGetQueryKey(projectId) }),   // 本 PR 新增
  ]
}

「边沿触发」有个连带后果:当一份带旗 delta 被更新的快照取代(superseded)而丢弃时,它的 board 载荷可以安全丢,但旗标不能——没有更新快照会重新断言它。所以 dispatcher 在三处(正常应用 / superseded / 断线 resync)都保证旗标不丢,carryMetaFlag 会把被丢弃 delta 的旗标转移到保留的那一份上。这层机制是旅程 D 能成立的前提。

配方 · 新增一个模板生命周期操作的标准步骤:① 在 @buffin/api 加 schema(templateLifecycleSchema 复用 expectedVersion)+ 错误码 + status 映射;② TemplateService 里事务内按「version → 状态前置 → 领域守卫」顺序检查,再 guarded UPDATE(eq(version) + 状态谓词),未命中走 throwTemplateWriteConflict 区分 stale-version 与同版本竞争;③ wireServices 接线时先读 projectIdOfTemplate,写成功后 publishBoardSnapshot(projectId, {boardMetaChanged:true});④ CLI 挂到 templateLifecycleCommand 工厂;⑤ 加契约测试。

6旅程 A:创建任务

这是最能体现「服务端真相源」原则的一条旅程。走通它,你会明白为什么点看板某一列的「+」号,建出来的任务不一定落在那一列,以及远端把你正选着的模板归档时弹窗为什么会自己换一个模板。

全景 · 涉及 5 个文件
点「+」
BoardColumn.tsx
算初值
create-task-assignment.ts
双字段弹窗
CreateTaskDialog.tsx
解析落库
tasks.ts · resolveCreateStage
跟随 focus
board.tsx

A.1点「+」发的不再是 stageId,而是「来源上下文」

以前看板列脚点「+」,直接把这一列的 stageId 当创建真相传进去。现在改传一个来源对象:aggregate(聚合)视图传 band、focus(聚焦某模板)视图传真实 stage。这个对象只播种初值,不约束用户后续的选择。

apps/desktop/src/renderer/features/tasks/create-task-assignment.ts真实代码(节选)
/** 播种但不约束一次创建分配的看板上下文 */
export type CreateTaskOrigin = {
  readonly band: StageBand         // 聚合列的 band,取其默认模板首个匹配 stage
  readonly stageId: string | null  // 聚焦模式点中的真实 stage,聚合下为 null
}

export function resolveCreateTaskAssignment(project, templates, origin): CreateTaskAssignment | null {
  const focusedTemplate = origin.stageId === null ? undefined
    : templates.find((t) => t.stages.some((s) => s.id === origin.stageId))   // 聚焦:含该 stage 的模板
  const template =
    focusedTemplate
    ?? templates.find((c) => c.id === project.defaultTemplateId)   // 否则:项目默认模板
    ?? templates[0]                                                // 再否则:第一个(见偏差 #4)
  if (!template) return null
  const stage =
    (origin.stageId === null
      ? template.stages.find((c) => c.band === origin.band)   // 聚合:该模板首个匹配 band 的 stage
      : template.stages.find((c) => c.id === origin.stageId)) ?? template.stages[0]
  return stage ? { templateId: template.id, stageId: stage.id } : null
}

把「点哪列建哪」的隐式规则外化成这个可测的纯函数,是本 PR 的一个新先例——它有独立的 create-task-assignment.test.ts(四场景全覆盖),旅程里每一处需要初值的地方(弹窗打开、远端归档后重置)都调它,不再各写一套。

A.2弹窗:Template 在前,Stage 在后,改模板则 stage 跟随重置

创建弹窗新增两个并排字段——先选 Template(只列 Active),再选该模板的 Stage。打开时用一个 effect 按 seedKey 一次性播种;改模板时把 stage 重置为新模板第一个 stage。提交门槛是「repos 与 templates 都加载完 选中的 stage 存在」。

apps/desktop/src/renderer/features/tasks/components/CreateTaskDialog.tsx真实代码(节选)
function chooseTemplate(nextTemplate: TaskTemplateDto) {
  setTemplateId(nextTemplate.id)
  setStageId(nextTemplate.stages[0]?.id ?? '')   // 改模板 → stage 跟随新模板首个
}

async function submit() {
  if (!canSubmitDraft(draft) || !templateId || !stageId || submitting) return
  const task = await createTask.mutateAsync({
    templateId, stageId,          // GUI 创建始终两个身份都发,绝不靠 stage 隐式代表模板
    title: draft.title.trim(),
    /* ...description / priority / repos / labels... */
  })
  onCreated(task)                 // 回的是整个 TaskDto,board 要读回 task.templateId 决定跟随
}

const ready = projectRepos.isSuccess && templatesQuery.isSuccess && selectedStage !== undefined

A.3远端归档所选模板:渲染期直接重置,不等 effect

一个竞态:弹窗开着,另一个客户端把你正选着的模板归档了——它从 active 列表消失,两个下拉会停在占位、Create 静默禁用。修法不用 effect,而是在渲染体里直接判:选中的 templateId 已不在最新 active 列表里,就立刻用幸存模板重算。

apps/desktop/src/renderer/features/tasks/components/CreateTaskDialog.tsx真实代码(节选)
// 渲染期重置一个被世界作废的选择:远端归档把选中定义从 active 列表里删了,弹窗还开着——
// 保留旧 id 会把两个下拉钉在占位、Create 静默禁用,所以从幸存模板重新播种
if (templateId !== '' && templatesQuery.data
    && !templates.some((template) => template.id === templateId)) {
  const assignment = project.data
    ? resolveCreateTaskAssignment(project.data, templates, origin) : null
  setTemplateId(assignment?.templateId ?? '')
  setStageId(assignment?.stageId ?? '')
}

同一手法在跨模板迁移控件 TaskTemplatePicker 里也有一份(旅程 C)。

A.4落库后:如果建到了聚焦视图之外的模板,就切 focus 跟过去

daemon 侧 resolveCreateStage(见 §5.1)落库;回到 board,onCreated 拿到完整任务后,如果当前聚焦的是模板 X 而新任务落在模板 Y,就把 URL focus 切到 Y——避免「创建成功但卡片从当前视图消失、详情停在看不见的任务上」。

以前
列脚「+」→ 传 draftStageId
stage 下拉把所有模板的 stage 混在一起分组,用户从重复的 Todo/Done 里猜模板
创建成功仅回 taskId,不处理「卡片落到别的视图」
另有第二套 draft 创建路径并存(详情面板内联)
现在
列脚「+」→ 传 CreateTaskOrigin(band / 真实 stage)
Template + Stage 两个独立字段,Template 只列 active
整个 TaskDto,按 task.templateId 决定是否 focusTemplate 跟随再选中
唯一创建路径(UnsavedTaskPanel 已删)
排查路标 · 旅程 A
症状从哪下手
建的任务落错模板 / 忽略了项目默认tasks.tsresolveCreateStage(服务端解析);GUI 侧 create-task-assignment.ts 的 resolver
弹窗里 Template 下拉缺了某个模板CreateTaskDialog.tsxTemplatePicker 只吃 useProjectTemplates(active,不含归档)
远端归档后弹窗卡住 / Create 灰着点不动CreateTaskDialog.tsx:渲染期重置那段 if;看 project.data 是否还没加载(偏差存疑 3)
建完卡片不见了、详情停在空处board.tsxonCreated 里的 focusTemplate(task.templateId) 跟随分支
stageCrossTemplate / stageCrossProjecttasks.tsresolveCreateStage 的两道归属检查(§5.1)

7旅程 B:Settings 里的模板生命周期

这条旅程从零新建:以前 renderer 没有任何模板管理界面(trpc 层有可变 API 但没人消费)。现在 Settings → Projects → 项目详情下挂一个 492 行的管理页,Active / Archived 分区,承载创建、复制、设默认、归档、恢复、永久删除。

全景 · 涉及 4 个文件
管理页
ProjectTemplatesSettings.tsx
数据 hooks
use-templates.ts
事务守卫
templates.ts
发旗标 delta 保活订阅
client-provider.tsx

B.1不可变:管理页没有「编辑」,改动只能靠复制

页面按 archivedAt 把模板分成 Active 与折叠的 Archived 两区。Active 行有「设默认(非默认才显示)/ 复制 / 归档」,归档按钮在「是默认 只剩一个 active」时禁用;Archived 行有「复制 / 恢复」,canDelete 为真时才出现永久删除。整页没有 update/editStage 路径——这正对应服务端把可变 API 全删的事实。复制草稿有两处防御:颜色选项从 schema tuple 派生(新增色不会漏进 picker),复制名先按 120 上限截断(受控 input 不截断程序化赋值,先截好过提交被拒)。

apps/desktop/src/renderer/features/settings/components/ProjectTemplatesSettings.tsx真实代码(节选)
// 从 schema tuple 派生,新色板成员绝不会静默地漏出 picker
const COLORS = stageColorSchema.options

// 本地化的「<源名> copy」种子,压在 schema 上限内;截断的种子仍可编辑,好过提交后被服务端校验拒
function copyName(sourceName: string): string {
  const suffixLength = t('settings.templates.copyName', { name: '' }).length
  return t('settings.templates.copyName', {
    name: sourceName.slice(0, Math.max(1, TEMPLATE_NAME_MAX - suffixLength)),
  })
}

B.2永久删除:行内两击确认,不叠第二层模态

Settings 本身已是一个模态面,设计文档禁止再叠模态。所以不可逆的物理删除用一个 confirmDeleteId state 做「武装 → 确认」两击守卫:第一击把该行换成确认+取消两个图标,第二击才真删。

apps/desktop/src/renderer/features/settings/components/ProjectTemplatesSettings.tsx真实代码(节选)
{template.canDelete && (confirmDeleteId === template.id ? (
  <>
    <RowAction label={t('settings.templates.confirmDelete')} destructive
      onClick={() => remove.mutate(
        { templateId: template.id, expectedVersion: template.version },
        { onSettled: () => setConfirmDeleteId(null) })}>   // 第二击才真删
      <Trash2 /></RowAction>
    <RowAction label={t('common.cancel')} onClick={() => setConfirmDeleteId(null)}><X /></RowAction>
  </>
) : (
  <RowAction label={t('settings.templates.delete')} destructive
    onClick={() => setConfirmDeleteId(template.id)}><Trash2 /></RowAction>  // 第一击:武装
))}

B.3服务端守卫:事务内按固定顺序检查,再做 guarded UPDATE

archive 是三态守卫的代表:版本 → 已归档? → 是默认? → 是最后一个 active?,全在一个事务里检查并写入。写用 eq(version) + isNull(archivedAt) 双谓词,未命中则 throwTemplateWriteConflict 区分「版本过期」与「同版本生命周期竞争」。

apps/daemon/src/services/templates.ts · archive真实代码(节选)
this.db.transaction((tx) => {
  const current = this.getTemplateRow(id, tx)
  assertVersion(current.version, input.expectedVersion, 'template')
  if (current.archivedAt !== null) throw new AppError({ code: BuffinErrorCode.template.archived })
  const project = this.projects.getActiveRowFrom(tx, current.projectId)
  if (project.defaultTemplateId === id)
    throw new AppError({ code: BuffinErrorCode.template.isDefault })   // 当前默认不能归档
  // 默认恒为 active,故唯一的 active 必是默认、isDefault 先触发;此守卫是纵深防御
  if (this.activeTemplateCount(tx, current.projectId) <= 1)
    throw new AppError({ code: BuffinErrorCode.template.lastActive })  // 最后一个 active 不能归档
  const result = tx.update(taskTemplatesTable)
    .set({ archivedAt, updatedAt: archivedAt, version: current.version + 1 })
    .where(and(eq(taskTemplatesTable.id, id), eq(taskTemplatesTable.version, current.version),
               isNull(taskTemplatesTable.archivedAt)))
    .run()
  if (result.changes !== 1) this.throwTemplateWriteConflict(id, input.expectedVersion)
})

物理删除(delete)的守卫链是:未归档 → template.mustArchivetemplateTaskCount > 0template.inUse。关键在于 templateTaskCount不带 activeTaskWhere() 的裸 count——它计任意生命周期状态的任务(含归档、含墓碑),这就把「可删」资格的翻转时机推到了物理删除之后(旅程 D)。

B.4Settings 面为什么要单独保活一条 board 流

Settings 是覆盖在任意路由之上的 overlay,它显示的项目常常不是当前活动看板项目。若没有自己的 delta 流,别的客户端归档/恢复模板或改默认时,这个面板会一直陈旧到重开。所以订阅并集里新增了第四个来源:settings 选中的项目。为什么复用 board 流而不是新开模板通道——因为模板写在其他客户端正是通过 board 流的 boardMetaChanged 触发失效的(§5.2)。

apps/desktop/src/renderer/lib/client-provider.tsx · activeDispatcherSubscriptions真实代码(节选)
const boardProjectIds = new Set(sessionBoardProjectIds)   // 持有 open session 的项目
if (projectId !== null) boardProjectIds.add(projectId)            // 活动看板项目
if (settingsProjectId !== null) boardProjectIds.add(settingsProjectId)   // 本 PR 新增:Settings 面
for (const id of boardProjectIds) subscriptions.push({ topic: 'board.deltas', projectId: id })
排查路标 · 旅程 B
症状从哪下手
归档被拒(默认 / 最后一个)templates.ts · archiveisDefault / lastActive 守卫顺序
永久删除按钮该出现却没出现服务端 canDeletedeletableTemplateIds);GUI 只在 template.canDelete 真时渲染——多半是旅程 D 的翻转还没到
删除报「必须先归档」/「仍被使用」templates.ts · deletemustArchive / inUsetemplateTaskCount 计任意状态)
Settings 面模板列表 / 默认徽章不刷新client-provider.tsxsetSettingsProject 订阅槽;ProjectSettingsDetail 挂载期注册
写冲突报 resource.conflict 而非版本过期templates.ts · throwTemplateWriteConflict:同版本生命周期竞争的分类

8旅程 C:任务跨模板迁移

跨模板迁移是本 PR 引入的一个独立领域操作,它的价值不是「保留迁移历史」,而是把 Project / Template / Stage / 归档状态 / 乐观版本这五重校验集中在一处,且明确不碰任务的执行现场(session、worktree、run、Activity)。它绝不能从通用 update 或拖拽绕过。

全景 · 涉及 3 个文件
两步控件
TaskTemplatePicker.tsx
单事务迁移
tasks.ts · changeTemplate
跟随 + 对账
board.tsx

C.1控件:选目标模板 → 选目标 stage → 确认,两步不可跳

详情面板 stage 行上方新增 Template 行。下拉自动排除任务当前模板;选中后弹出确认小面板(目标名 + 目标 stage 选择器 + 取消/确认)。用 task.version 做乐观并发。这里同样有渲染期重置——目标被远端归档、或任务已被并发迁移到该目标(再提交只会得 templateUnchanged),就折叠面板并遗忘选择。

apps/desktop/src/renderer/features/tasks/components/TaskTemplatePicker.tsx真实代码(节选)
// 渲染期重置一个被世界作废的选择:远端归档的目标离开 activeTemplates 会折叠确认面板,
// 但保留的 id 在目标被恢复时会静默复活它;且一旦任务已落在所选目标上,提交只会得 templateUnchanged
if (targetTemplateId !== null &&
    (targetTemplateId === task.templateId ||
     !activeTemplates.some((template) => template.id === targetTemplateId))) {
  setTargetTemplateId(null)
  setTargetStageId(null)
}
/* ... */
function confirm() {
  if (!target || !targetStageId || disabled || change.isPending) return
  change.mutate(
    { taskId: task.id, targetTemplateId: target.id, targetStageId, expectedVersion: task.version },
    { onSuccess: () => { onChanged(target.id); cancel() } },   // 成功后通知 board 跟随 focus
  )
}

值得留意的双数据源:详情面板的迁移目标读 active 列表(useProjectTemplates,不含归档),而同模板 stage 操作读 board 快照的 templates——因为 board 快照仍被引用的归档模板,所以停在归档模板上的任务,它的 stage 仍可操作。

C.2服务端:一个事务,五重校验,只改三列

daemon 侧 changeTemplate 用 assignable 查找定目标模板、operational 查找解目标 stage(同 §5.1 的哲学),校验通过后只更新 stageId / position / version——taskRepos、sessions 一概不碰(契约测试用 before/after 行相等来钉这一点)。

apps/daemon/src/services/tasks.ts · changeTemplate真实代码(节选)
this.db.transaction((tx) => {
  const task = assertFound(/* anyTaskWhere 选行,含归档 */, BuffinErrorCode.task.notFound)
  assertTaskMutable(task)                                  // 归档任务报 archived 而非 notFound
  assertVersion(task.version, input.expectedVersion, 'task')
  const targetTemplate = this.templates.getAssignableTemplateRow(input.targetTemplateId, tx)  // 归档目标→archived
  const target = this.templates.getOperationalStageContext(input.targetStageId, tx)
  if (target.projectId !== task.projectId || targetTemplate.projectId !== task.projectId)
    throw new AppError({ code: BuffinErrorCode.task.stageCrossProject })
  if (target.template.id !== targetTemplate.id)
    throw new AppError({ code: BuffinErrorCode.task.stageCrossTemplate })
  const currentTemplateId = this.templates.templateIdOfStage(task.stageId, tx)
  if (currentTemplateId === targetTemplate.id)
    throw new AppError({ code: BuffinErrorCode.task.templateUnchanged })   // 目标即当前 → 白干
  // guarded update:stageId + nextPosition(target) + version+1(不触碰执行现场资源)
})

C.3迁移之后:先切 focus 跟过去,再让对账 effect 清理越界选择

迁移把任务送到别的模板列,如果当前正聚焦源模板,卡片就会消失而右侧详情还活着——这正是设计文档 §8.5 要防的「渲染视图之外的过期详情」。两段逻辑配合解决:onChanged 先切 focus 跟随;board 上一个对账 effect 负责清理那些没被跟随、真正越界的选择。

apps/desktop/src/renderer/routes/board.tsx · §8.5 对账 effect真实代码(节选)
useEffect(() => {
  // 四个提前返回是设计要点,缺一个就会误清
  if (scope.kind !== 'template' || !board || selectedTaskId === null || taskWritePending) return
  const selected = board.columns.flatMap((c) => c.tasks).find((t) => t.id === selectedTaskId)
  if (!selected || selected.templateId === scope.templateId) return   // 不在快照里=removed 观察器的活
  clearPreview()
  setSelection(null)
}, [scope, board, selectedTaskId, taskWritePending])

四个守卫各挡一种误清:!board 挡 history back 落在 board 解析前;taskWritePending 挡迁移窗口(此刻 board 还把任务列在旧模板下,onChanged/onCreated 会先 focusTemplate 对齐);!selected 把「任务根本不在快照」交给 removed-task 观察器,不当作 scope 不匹配。因为创建/迁移都「先 focus 自己模板再选中」,跟随中的选择永不被误清。

排查路标 · 旅程 C
症状从哪下手
迁移后 session / 终端 / worktree 断了不该发生:tasks.ts · changeTemplate 只改 stageId/position/version,契约测试钉了行相等
迁移报 templateUnchangedtasks.ts · changeTemplate:目标模板与当前相同;或控件渲染期重置已折叠面板
迁移后卡片消失、右侧还停着旧详情board.tsx:先看 onTemplateChanged → focusTemplate 跟随;再看 §8.5 对账 effect 四守卫
迁移中选择被莫名清掉board.tsx:对账 effect 的 taskWritePending 守卫(迁移窗口不该清)
归档模板上的任务 stage 下拉空了SavedTaskPanel.tsx:stage 源应读 board.data.templates(含归档)而非 active 列表

9旅程 D:canDelete 的两段式翻转

这条旅程走的是一个看不见的事件链,也是审查里两轮才闭环的那个 must-fix。问题是:模板的「可以物理删了」这个资格,究竟在哪一刻翻转,又靠把这个翻转推给正开着 Settings 面板的用户。答案是——它分两段,中间隔着一个异步维护线程。

D.1时间线:为什么删掉最后一个任务,canDelete 还不翻

Buffin 的任务删除是墓碑化(打 purge_requested_at)+ 异步 reaper 物理删行两段式。而 canDelete 的判据 templateTaskCount任意状态的任务引用(含墓碑)。所以:用户删掉归档模板上最后一个任务的那一刻,行还在(只是墓碑),stage 外键还挡着模板物理删除,canDelete 依旧是 false。它只有等 reaper 真正把行删掉才翻 true

第一段 · tasks.delete 当场
子树打墓碑(purgeRequestedAt
发一份 带旗 delta(模板任务计数变了,列表要刷)
canDeletefalse——计数含墓碑行,stage FK 还挡着删除
第二段 · reaper 物理删行后
维护线程 runOnce 物理删墓碑任务行
onTasksReaped(projectIds) 钩子触发
补发第二份带旗 delta,canDelete 此刻翻 true

D.2第一段:删除当场发旗,但注释直说「翻转还没来」

tasks.delete 的接线现在发带旗 delta(以前不带旗),但注释诚实地标明这只修了常见竞态,真正的翻转由维护钩子补发——这段注释本身就是审查修复留下的路标。

apps/daemon/src/trpc/services.ts · tasks.delete真实代码(节选)
const { deletedTaskIds, ...result } = await deps.taskUseCases.delete(id)
/* ...扫终端、reconcile watcher... */
// 带旗:子树硬删改变了客户端缓存的模板任务计数,其模板列表须重取。canDelete 的翻转本身
// 稍后才落地——计数跨越墓碑行(stage FK 在 reaper 删掉它们前一直挡着模板删除),
// 由维护 tick 的 onTasksReaped 钩子在物理行消失后补发那份带旗 delta。
publishBoardSnapshot(projectId, { boardMetaChanged: true })

D.3第二段:reaper 返回被删项目集,钩子补发那条唯一的带外 delta

reaper 以前只删行、不通知任何人。现在 reapTombstonedTasks 返回 {deleted, projectIds}runOnce 在删掉行后调 notifyTasksReaped;这个钩子是 best-effort 的——投影抛错只 log,绝不失败整个维护 pass 或搁浅积压重试。

apps/daemon/src/maintenance/maintenance-tick.ts · runOnce / notifyTasksReaped真实代码(节选)
const reaped = await this.reapTombstonedTasks()
result.tasksDeleted = reaped.deleted
if (reaped.deleted > 0) this.notifyTasksReaped(reaped.projectIds)   // 有行被删才通知
/* ... */
private notifyTasksReaped(projectIds: string[]): void {
  // best-effort:一个抛错的 publisher 只 log,绝不失败维护 pass 或搁浅积压重试
  try { this.onTasksReaped(projectIds) }
  catch (err) { logger.warn({ err }, 'task reap notification failed') }
}

组合根(daemon index.ts)把 onTasksReaped 接到 同一个 createBoardSnapshotPublisher(§5.2),于是 reaper 的补发和传输层的 mutation 共用一个订阅者守卫、一条投影路径。onTasksReaped 收到被删项目集后,逐个 publishReapedBoards(id, {boardMetaChanged:true})——这是整个系统里模板可删态唯一的带外推送通道。这条通道在本 PR 之前完全不存在,客户端只能靠下次 refetch 才看到「可删了」。

为什么这段值得单独一节:它是「结构性缺口」的典型——任何未来由 reap 派生的客户端可见字段(不止 canDelete),都会继承同一个缺口:维护线程里没有天然的发布缝隙。本 PR 补的这条 onTasksReaped 缝隙是它们共同的出口。trpc-wss 测试用「delete 发第一条带旗 → 查 canDelete:falsemaintenance.runOnce() → 第二条带旗 → canDelete:true」把两段语义都钉住了。
排查路标 · 旅程 D
症状从哪下手
删完最后一个任务,永久删除按钮迟迟不出现预期行为:等维护 tick 跑一轮。看 maintenance-tick.ts · reapTombstonedTasks 是否有 worktree 故障把任务搁浅
reaper 跑了但 Settings 面没刷新index.tsonTasksReaped 是否接到了 publishReapedBoardstrpc/services.ts · createBoardSnapshotPublisher 的订阅者守卫
补发 delta 报错但没影响别的预期:notifyTasksReaped / createBoardSnapshotPublisher 的 try/catch 只 log(这两处无测试兜底)
canDelete 计算拖慢每次 board publishtemplates.ts · deletableTemplateIds:单条 grouped 查询 + archivedIds 空则 early-return(成本压在热路径,见存疑)

10计划 vs 实现的偏差

照设计文档做成的部分不必再看;真正的认知裂缝在偏差里。这些从随附的双视角审查 run log 挖出——四轮审查(arch ×2、cross ×2)共确认 27 条发现,26 条落地修复。下面只收对「理解代码」有意义的实质偏差。

计划是什么实际做成什么为什么变 / 状态
默认模板变更要刷新 Project 缓存(§6.4) 初版 projects.update 改默认后发任何 delta,dispatcher 也只失效 templates 不失效 project,其他客户端持续把任务建进旧默认 arch r1 修复:默认变更时发带旗 delta,invalidateBoardMeta 扩到同时失效 projects.get✅ 已闭环
硬删任务后模板可删态要能推送 初版 tasks.delete 发的快照不带旗,且 reaper 根本没有发布缝隙——canDelete 的 false→true 永远推不到客户端 arch r1 修一半(发旗),arch r2 以 onTasksReaped 缝隙闭环(旅程 D)。✅ 已闭环
显式创建的错误码应指向用户选的东西(§6.5) 初版给「归档兄弟模板的 stageId」报 template.archived——指向调用者根本没选的模板 arch r2 修复:归属检查前置,改经 operational 查找,正确报 stageCrossTemplate(§5.1)。✅ 已闭环
模板创建的领域检查应在事务内(§6.5 精神) 初版重名检查与 position 推导跑在事务外,未来任何 async 缝隙下会退化成裸 SQLite UNIQUE 错误 arch r1 修复:assertTemplateNameAvailable + nextTemplatePosition 移入 insert 事务。✅ 已闭环
scope 变更后离开视图的选择必须清理(§8.5) 初版只做了创建/迁移半边,用户主动 focus 切换那半边没做——越界任务的详情面板带着活的 stage 药丸和删除按钮继续挂着 cross r1 修复:board 新增对账 effect + taskWritePending 门控(旅程 C.3)。✅ 已闭环
复制模板要求用户输入新的唯一名(§8.2) 实际预填 <源名> copy(三语 + 120 截断),预填-而非-要求-输入的偏离仍在;同源重复复制仍会撞名往返 arch r1 仅缓解。⚠️ 遗留「低优清理」
受控色板选择(§8.2) 颜色用原生 <select><option> 装不下 StageDot 色块,只能用本地化文字表意 cross r1 修 i18n 半条;无色块的原生 select 仍低于「受控色板」标准。⚠️ 遗留「低优清理」
默认是服务端真相,客户端无替补(§4.4) resolveCreateTaskAssignment 在默认缺席时回落 templates[0],静默掩蔽被破坏的默认不变式 有意未修。⚠️ 遗留「架构需决策」(见 §13 合并前必办)

过程元信息:这是一个 agent 代写 + 双视角(Claude + Codex)审查的 PR。审查分四轮迭代修复,每轮门禁全绿。流程层面有个观察——arch r1 与 cross r2 两轮 Codex 未产出,实际退化为单审查者,双视角覆盖打了折扣。此外 d3b9479fcheck-retired-name 门禁)是一笔与模板特性无关的搭车提交,混进了同一审查区间。

11心智模型补丁

读完之后,你对这个项目的假设需要改这几处:

任务的 stageId 就够了,模板由 stage 隐式决定,通用 update 也能换 stage/换模板。 stage 不再隐式代表模板意图。创建必须显式带 templateId(或全省略走默认);update 删掉了 stageId;同模板换列走 move,跨模板走独立的 changeTemplate
模板可以 update、可以增删改重排 stage。 模板定义创建后完全不可变。服务端 templates.update / templates.stages.* / reorder 全部删除,CLI 对应命令组也删了。要改就复制一个新模板再迁移任务。
key / band / position 现在全由服务端从 {name,color}[] 的数组顺序派生(首 todo、尾 done、中 doing)。
删除模板 = 软删(deletedAt),既表达「删了」又表达「不可用」。 归档(archivedAt)与物理删除是两件事。归档可恢复、无级联副作用,只下架分配资格;物理删除是终态,只对没有任何任务引用的归档模板开放。
删掉模板上最后一个任务,它立刻就能删了。 canDelete 在 reaper 物理删行后才翻转,不是删任务当场。中间隔着异步维护线程,靠 onTasksReaped 补发 delta 推给客户端(旅程 D)。
GUI 当前页碰巧传入的 stage 能决定任务建进哪个模板。 项目默认模板是服务端真相源,GUI / CLI / API 走同一套默认规则。默认变更会发带旗 delta 刷新所有客户端的 Project 缓存。
stage 颜色是 daemon 发来的任意 CSS 字符串,StageDot 内联渲染。 颜色是六值受控枚举 StageColor,renderer 映射到专用tag-* 主题 token 族——改语义色(agent-state / danger)不会再误染看板列。
模板名只对 active 唯一,归档后同名可复用。 名字在 active + archived 范围内共同唯一(DB 唯一索引去了 partial 谓词)。归档模板仍占名字,只有物理删除后名称才释放——否则 restore 会变成冲突操作。

12新词表

仅本 PR 新增、不能假设你已知的名词,按域分组:

领域概念
不可变定义 (definition)模板行 + 其 stage 行作为一个整体,创建后不可改。
Active / Archived / Deleted模板三态:archivedAt=null 为 Active;非 null 为 Archived(可恢复、无级联);物理删行为 Deleted。
archive / restore下架/恢复模板的分配资格,取代旧的 update/reorder 可变面。
changeTemplate把任务显式迁到另一 active 定义的独立领域操作,不走通用 update 或拖拽。
canDelete服务端派生的「该归档定义无任何状态的任务引用」标志,随 board publish 计算并嵌入 DTO。
assignable / operational / readable三种 stage 查找语义,分别服务「选新目标」「解析已有任务的 stage」「渲染/管理列表」。
band / key(派生化)stage 的语义带(todo/doing/done)与稳定键不再由客户端提供,服务端从数组顺序 + name 派生。
基础设施 / 数据流
boardMetaChangedboard delta 上的边沿触发旗标,请求客户端顺带失效模板列表 + 项目详情两个不订阅 delta 的缓存。
createBoardSnapshotPublisher被传输层 mutation 与 reaper 共用的 board 快照发布器,含订阅者守卫。
onTasksReaped维护 tick 物理删任务行后的回调缝隙,用于补发那条唯一的带外 board delta。
invalidateBoardMetadispatcher 里把 boardMetaChanged 覆盖的两个缓存(templates.list + project.get)一起失效的 helper。
throwTemplateWriteConflictguarded write 未命中时,区分「版本过期」与「同版本生命周期竞争」的分类器。
前端
resolveCreateTaskAssignment把「看板上下文 → 弹窗初始 (template, stage)」外化的可测纯函数;播种但不约束。
CreateTaskOrigin点「+」传的来源对象(band + 可能的真实 stageId),取代以前直接传的 stageId。
ProjectTemplatesSettingsSettings 项目级模板管理页(Active/Archived 分区、创建/复制/生命周期)。
TaskTemplatePicker详情面板里两步(选模板 → 选 stage → 确认)的跨模板迁移控件。
settingsProjectId 订阅槽client-provider 订阅并集里为 Settings 面保活 board 流的第四个来源。
tag-* token 族stage 颜色专用的主题变量族,本 PR 补齐 gray/amber/red 三成员。

13测试与风险地图

纯事实陈述:哪些行为有测试钉住,哪些重要逻辑走在薄冰上。分支未 push——合并前 CI 首跑即最后一道验证。

有兜底的

薄冰(重要逻辑无测试兜底 / 已知遗留)

合并前必办事项(来自 run log 的「架构需决策」三项):这三条不是 bug,是需要一次显式决策的开放问题——① 存在性掩蔽哲学统一:task 与 project 两条写路径对「外项目/归档」持相反的错误语义,选一种全库统一,或把「为何不同」升格进设计文档正文;② resolveCreateTaskAssignmenttemplates[0] 回落:改为显式报错/空态暴露坏默认,或在设计文档为该防御性回落背书;③ template.lastActive 守卫的可达性:当前被 isDefault 永久遮蔽,若想给「唯一活动模板」更可行动的报错,需有意识重排守卫并补钉测试。CI 层面:四个修复提交每次均通过 check + typecheck + test + lint,pre-commit 复跑全绿,无 --no-verify

14验收提示

验收时别把这些误判为缺陷:

15覆盖声明

本报告基于全量精读,不抽样。主力(我)亲自 Read 了报告中出现的每一段代码所在文件:daemon 的 templates.ts / template-definition.ts / tasks.ts / projects.ts / trpc/services.ts / maintenance-tick.ts;desktop 的 create-task-assignment.ts / CreateTaskDialog.tsx / TaskTemplatePicker.tsx / ProjectTemplatesSettings.tsx / board.tsx / client-provider.tsx / use-templates.ts;契约层 schemas.ts / dto.ts / errors/codes.ts / dispatcher.ts / db/index.ts;CLI 的 task.tsproject.ts 模板命令段。报告中所有代码片段均出自这些文件、经亲手裁剪(保留原文,// ... 省略无关行)。

四个子系统(daemon / desktop / cli / packages+杂项)各另有一个 subagent 全量精读 diff 做地图与交叉印证,覆盖每一行改动,包括本文未展开的机械跟随文件(9 个测试 fixture 适配、i18n 三语 key、seed/createTask 补参、migrations 生成物、4 个 check-*.mjsexistsSync 过滤)。「计划 vs 实现的偏差」(§10)与「合并前必办」(§13)取自随附的设计文档与四轮双视角审查 run log,结论已内联,不要求读者回看原文。