#70 lifecycle-convergence:把 task/session/project 的「下线」收敛成一个状态机

figuretu/eyrie · 4fbeed4...a762ad5(base = merge-base,head = squash + 2 个修复 commit) · 2026-06-28 · 自包含,读完即弃

3 commits(squash + 2 修复)
66 文件
+3296 / −412
~59% 是测试代码
类型:功能 + 重构 混合

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

1TL;DR

这个 PR 把 task(看板卡片)、agent_session(绑在 task 下、带运行中 runner 子进程的 Agent 会话)、project 三类实体的「下线」语义,从一盘散沙收敛成一个状态机、一套谓词。终态只有三态,两个互斥的存储态加一个物理终态:activearchived_at IS NULL,可见可改可跑)、archivedarchived_at IS NOT NULL,隐藏、不可改、不可跑、不占资源、可恢复——唯一的软删轴)、deleted(行不存在,物理删 + 外键级联,无回收站)。

动机:之前每个 service 各自手写 isNull(deletedAt) 过滤,每加一个入口就漏一次——归档的 session 还能被 startTurn 跑起来、归档的 task 还能被 move/reparent、session 的两个读面一个滤一个不滤。本 PR 新建 apps/daemon/src/db/lifecycle-predicates.ts 作为唯一真相源,让每一个读 / 写 / 跑 / 占用入口都路由经它;同时把早前一版「session 级软删 + restore」的设计整个推翻,换成 task 级 archive(连同其下 session 一起停泊)+ 物理 hard-delete。新增两个错误码 task.archived / session.archived(均 409),并彻底删掉三张表的 deleted_at 列。

2变更地图(称重)

这是一个设计密度极高、但生产代码很紧凑的 PR。近六成是测试——契约测试把新状态机的每条承诺都钉死了。真正承载设计的生产代码只有 ~1,300 行,且高度内聚(一个谓词模块 + 一个级联模块 + 散布到各 service 的守卫)。没有大段机械搬运可略过——除了 schema 快照/journal 是生成物。

daemon/tests
2019 行 · 58%
daemon/src
1273 行 · 37%
packages/api
78 行 · 2%
packages/db
80 行 · 2%
cli + desktop
36 行 · 1%
设计重心(要细读) 可放心略过
db/lifecycle-predicates.ts(新,谓词真相源)· db/cascade.ts(级联原语)· use-cases/tasks.ts(新,archive/unarchive/delete 编排)· agent/service.ts(运行守卫 + teardown + deleteSession)· agent/drizzle-repository.ts(createSession TOCTOU、requireActive CAS、hardDeleteSession)· services/tasks.ts / sessions.ts(mutable / visible 守卫)· services/repos.ts / labels.ts / projects.ts(占用计数) migrations/meta/0000_snapshot.json + _journal.json(drizzle 生成物)· 各 fixture 的 deletedAt:null → archivedAt:null 字段重命名(30+ 测试文件的机械改动,由 schema 变化驱动)· cli/serve.test.ts 超时 45s→60s(与生命周期无关)· desktop/use-tasks.ts 仅返回类型去掉 deletedAt

3架构一图流

这是一次真正的架构收敛:守卫的「调用方」不变,但「真相源」从「每个 service 各写各的 isNull」变成「全都问同一个谓词模块」;删除的语义从「写 deleted_at 软删 + restore」变成「物理删 + 外键级联」,软删那一轴重命名为 archive 并降格为「可恢复的隐藏」。

以前 · 散点过滤 + 软删轴

每个 service
各自手写 isNull(deletedAt)
tasks / sessions / projects
delete
写 deleted_at(软删)
行被隐藏
早前一版
restore(拟做)
回收站

现在 · 一套谓词 + archive/物理删

每个 service
引用
lifecycle-predicates.ts
archive(task 级)
写/清 archived_at(可逆)
隐藏但保留
delete
物理删 + FK cascade
行消失

4数据与状态先行

先把形状和词汇预载,后面旅程只讲行为。最核心的是这张状态机表。注意:这张表纠正了 GitHub PR 描述里的状态机表——PR 描述写 archived「占资源 = yes」,那是被推翻的旧设计;下表(以代码为准)是最终的「不占资源」。

状态 存储判定 列在看板 可改 可运行 占资源
active archived_at IS NULL
archived archived_at IS NOT NULL ✗(只能 unarchive) ✗(视作已消失)
deleted 行不存在

列的层面:deleted_at 从 tasks / agent_sessions / projects 三张表彻底移除;tasks 和 agent_sessions 各加一列 archived_atprojects 没有 archived_at——project 只能被硬删,不存在「归档 project」这个动作。session 的 archived_at 不是独立动词,永远由父 task 的归档级联写入。资源/关系表(labels、repos、project_statuses、task_repos…)保留各自的 deleted_at,那是一条独立的「资源退役」轴,本次不动。

packages/db/src/index.ts · tasksTable / agentSessionsTable真实代码(节选)
// tasksTable
- deletedAt: integer('deleted_at'),
+ // 写自 task 级 archive 轴(archive == 停泊、可逆);null = active
+ archivedAt: integer('archived_at'),
  ...
  boardIdx: index('tasks_board_idx')
    .on(table.projectId, table.statusId, table.position, table.id)
-   .where(sql`${table.deletedAt} IS NULL`),
+   .where(sql`${table.archivedAt} IS NULL`),   // 部分索引收敛到 archived_at

// agentSessionsTable
+ // 写自父 task 的 archive cascade(task 级归档流到这);不是独立 session 动词。null = active
+ archivedAt: integer('archived_at'),

仓库契约新增两个轻量类型,给跨资源编排和运行守卫用:

apps/daemon/src/agent/repository.ts真实代码(节选)
// 生命周期 teardown 收集用的轻量句柄:id 标识要 dispose 的 runner,cwd 定位工作目录
export type SessionRef = {
  id: string   // 今天只消费 id;cwd 先随 ref 发出,留给将来目录级 teardown 免二次查
  cwd: string
}
// session 行 + 父 task 生命周期态,供运行守卫判定
export type AgentSessionLifecycleRow = AgentSessionRow & {
  taskArchivedAt: number | null   // 父 task 的归档时间戳;null = 父 active
}

错误码与对外契约:新增 task.archived(409)/ session.archived(409),删除 task.hasActiveChildren(物理级联删后这个概念消失);TaskDto / SessionDto 各加 archivedAt: string | null;task 列表新增 includeArchived 布尔过滤(默认 false,且是唯一暴露归档项的读面,board 和 get-by-id 都保持 active-only);新增 tRPC 路由 tasks.archive / tasks.unarchive / agent.delete

5底座:谓词 + teardown

三条旅程都踩在两块底座上:集中谓词(决定「谁可见 / 可改 / 可跑 / 占资源」)和统一 teardown 边界(决定「停 runner 时怎么处理那条存活/将死的 run」)。先把这两块走通,旅程里就只讲各自特有的部分。

谓词真相源

整个模块只有六十行,但它是「收敛」二字的全部含义:所有 service 不再各写各的过滤,全部 import 这里的函数。注意三个细节——mutableTaskWhereactiveTaskWhere别名而非拷贝(避免两处谓词漂移);session 的可见/可运行要求 session 自身和父 task 未归档(靠 join tasks);anyTaskWhere 退化成 1 = 1,只服务 includeArchived 一条读路径。

apps/daemon/src/db/lifecycle-predicates.ts真实代码(节选)
export function activeTaskWhere(): SQL {
  return isNull(tasksTable.archivedAt)
}
// 普通 task 写入接受的行 = 可变 = 未归档
export const mutableTaskWhere = activeTaskWhere   // 别名:可变/可见/可运行全是「未归档」这一条

// 用户可见的 session 读:session 自身和父 task 都未归档
export function visibleSessionWhere(): SQL {
  return requirePredicate(and(isNull(agentSessionsTable.archivedAt), isNull(tasksTable.archivedAt)))
}
export const runnableSessionWhere = visibleSessionWhere   // 可运行 = 可见,同一函数

// 占用计数走 activeTaskWhere:归档 task 视作已消失,不再占 status/label/repo。
// 本谓词只为 include-archived 读路径存在。
export function anyTaskWhere(): SQL {
  return sql`1 = 1`
}

统一 teardown 边界

停掉一个 session 的运行时,归档和硬删有一个关键差别:归档要保留 run 行(可逆,所以活跃 run 必须落到终态,否则反归档后会看到一个「Running 但没有 runner」的鬼 run),硬删则 run 行马上被 DROP,结算它纯属浪费。这个差别压缩成一个 terminate 布尔。无论哪种,provider 侧的拆解(interrupt / dispose / 关 fan-out key)都先跑、无条件跑——flaky 的 provider 跳不过它,也不会把一个 idle 订阅者永久晾在一个将被删/停泊的 key 上。

apps/daemon/src/agent/service.ts · teardownSessionRuntime真实代码(节选)
async teardownSessionRuntime(sessionId: string, options: { terminate: boolean }): Promise<void> {
  const run = await this.repo.getActiveRun(sessionId)
  await this.teardownSessionRuntimeProviderSteps(sessionId, run)   // 永远先跑、无条件跑
  // 仅归档路径结算存活的 run 行;硬删路径的行马上被 caller DROP,故跳过
  if (run && options.terminate) await this.repo.terminateRun(run.id, 'interrupted')
}

private async teardownSessionRuntimeProviderSteps(sessionId, run) {
  if (run) {
    const handle = this.runnerManager.get(sessionId)
    if (handle) { try { await handle.runner.interrupt() } catch { /* best-effort */ } }
  }
  try {
    await this.runnerManager.dispose(sessionId)
  } finally {
    // dispose 抛错也要关 fan-out key,否则开着的 agent.events 订阅者会永久挂在一条即将被删/停泊的行上
    this.broadcaster.unsubscribeAll(sessionId)   // finally:关 key 不带权威 DB 写,无条件跑是安全的
  }
}

删除路径用的是 teardownSessionRuntimeBestEffort:provider 步骤抛错只 log(已提交的 DB 删除才是权威成功边界),但归档路径的 terminateRun 结算不被吞(它是权威 DB 写,吞了就会留下那个鬼 run)。

配方 · 给某资源新增一个会改它的入口时:① 读侧——若面向用户,过 visibleSessionWhere / activeTaskWhere(归档当 404);② 写侧——把 mutableTaskWhere() / isNull(archivedAt) 直接折进 UPDATE 的 where(不只在读时 assert,防 read→archive→write 窗口);③ 跑侧——过 getRunnableSession;④ 占用——计数用 activeTaskWhere()。四类入口各有对应谓词,别再手写 isNull

6旅程 A:归档 / 反归档(新增的可逆旅程)

这是 PR 带来的全新用户旅程:把一张卡片连同它下面所有 session「收起来」,之后还能原样拉回。走通这条你会掌握级联怎么保持原子、runner 怎么被停泊、以及为什么反归档不能从子节点做。

全景 · 涉及 4 个文件
tRPC 路由
packages/api/trpc.ts
接线
trpc/services.ts
编排
use-cases/tasks.ts
级联原语
db/cascade.ts
停 runner
agent/service.ts

A.1级联与原子性

归档不是改一行,而是「这棵子树的所有 task + 它们名下所有 session 一起打上同一个时间戳」。若 task 标记好了但 session 还活着,反归档后就会暴露一个半归档状态。所以两条 UPDATE 必须同事务,且各带一个 isNull(archived_at) 守卫——这让重复归档变成真正的幂等 no-op(已归档行被跳过,连 updated_at 都不刷新)。

apps/daemon/src/db/cascade.ts · archiveTaskTree真实代码(节选)
export function archiveTaskTree(tx, input: { ids: string[]; archivedAt: number }): void {
  tx.update(tasksTable)
    .set({ archivedAt: input.archivedAt, updatedAt: input.archivedAt })
    .where(and(inArray(tasksTable.id, input.ids), isNull(tasksTable.archivedAt)))   // 已归档跳过 → 幂等
    .run()
  // 同一事务里级联到 session:task 标记与 session 标记一起提交或一起回滚
  tx.update(agentSessionsTable)
    .set({ archivedAt: input.archivedAt, updatedAt: input.archivedAt })
    .where(and(inArray(agentSessionsTable.taskId, input.ids), isNull(agentSessionsTable.archivedAt)))
    .run()
}

子树 id 由 collectTaskSubtreeIds 广度优先收集(parent_task_id 没有 FK 级联,所以要在应用层手动收集后代)。

A.2停 runner 与幂等 ack

编排层的顺序很讲究:必须在归档之前收集 session ref——因为收集用的是 active-only 的 listSessionRefsByTasks,归档之后再查就查空了,runner 永远停不掉。归档后对每个 ref 做 terminate: true 的 teardown(行存活,活跃 run 落终态)。最后 ack 回显的是已持久化的时间戳,不是这次新生成的 nowMs()——这样重复归档不会广播一个 DB 根本没存的新时间。

apps/daemon/src/use-cases/tasks.ts · archiveTask真实代码(节选)
export async function archiveTask(deps, id): Promise<{ id: string; archivedAt: string }> {
  const row = deps.tasks.getActiveRow(id)            // archive-blind:已归档行也能解析(见 §13)
  const archivedAt = nowMs()
  const subtreeIds = deps.db.transaction((tx) => collectTaskSubtreeIds(tx, id))
  const refs = await deps.agentRepo.listSessionRefsByTasks(subtreeIds)   // 必须归档前收集
  deps.db.transaction((tx) => archiveTaskTree(tx, { ids: subtreeIds, archivedAt }))
  // terminate: true —— 行存活,活跃 run 必须落终态,否则反归档会暴露「Running 但无 runner」
  for (const ref of refs)
    await deps.agentSessions.teardownSessionRuntimeBestEffort(ref.id, { terminate: true })
  const persistedArchivedAt = row.archivedAt ?? archivedAt   // 已归档则回显已存值,绝不报新时间
  return { id, archivedAt: toIsoTimestamp(persistedArchivedAt) }
}

A.3反归档与部分恢复

反归档镜像归档,但多一道闸:必须从归档子树的根做。如果允许「父仍归档、单独恢复某个子节点」,看板上就会冒出一张「父是隐藏的」的 active 卡片。hasArchivedAncestor 走父链,发现任一祖先归档就拒 task.archived。反归档不碰任何 runner(归档时已经停光了),且 unarchiveTaskTree 的 session 级联带 isNotNull(archived_at) 守卫——已经 active 的 session 被跳过,updated_at 不刷新(真 no-op)。

apps/daemon/src/use-cases/tasks.ts · unarchiveTask真实代码(节选)
export async function unarchiveTask(deps, id): Promise<{ id: string }> {
  deps.tasks.getActiveRow(id)                         // 共享 delete/archive 的 not-found 契约
  // 拒绝部分反归档:祖先仍归档时恢复子节点 = 隐藏父下出现 active 卡片
  if (deps.tasks.hasArchivedAncestor(id)) {
    throw new AppError({ code: EyrieErrorCode.task.archived })
  }
  const updatedAt = nowMs()
  const subtreeIds = deps.db.transaction((tx) => collectTaskSubtreeIds(tx, id))
  deps.db.transaction((tx) => unarchiveTaskTree(tx, { ids: subtreeIds, updatedAt }))   // 不碰 runner
  return { id }
}

接线层(trpc/services.tstasks.archive / tasks.unarchive)在写完后用 projectIdOfTask 解析所属 project 并发一次 board 快照——因为 projectIdOfTask 是 archive-blind 的,读序相对写序无所谓。

排查路标 · 旅程 A
症状 从哪下手
归档后看板还显示这张卡 / session services/tasks.ts board()cascade.ts archiveTaskTree 的 session 级联是否同事务跑了
反归档后某 session 还是 Running 但点不动 use-cases/tasks.ts archiveTask 是否在归档前收集了 ref、teardown 是否 terminate:true
子任务恢复不了,报 task.archived services/tasks.ts hasArchivedAncestor——这是设计,须从子树根 unarchive
重复归档返回的时间戳每次都变 use-cases/tasks.ts persistedArchivedAt = row.archivedAt ?? archivedAt

7旅程 B:撞上归档的墙(收敛的回报)

这条旅程是整个 PR 的价值兑现点:一个对已归档实体的「读 / 写 / 跑 / 占用」请求,现在在每一个入口都会被挡住或当它不存在。下面挑四个有代表性的守卫逐跳看,其余在收尾表里枚举。

全景 · 守卫分布
运行守卫
service.getRunnableSession
写守卫
tasks.ts UPDATE where
建 session
drizzle-repository.createSession
close CAS
transitionSession requireActive
占用
repos/labels/projects

B.1运行守卫:两个错误码区分自身与父级

startTurn / resumeSession / blob 上传都先过 getRunnableSession。它读的是带父 task 归档态的 getSessionLifecycle(join 了 tasks),然后分两种归档来源给两个不同错误码:session 自身归档 → session.archived,父 task 归档 → task.archived。这让前端能告诉用户「是这条会话被归档了」还是「整张卡被归档了」。

apps/daemon/src/agent/service.ts · getRunnableSession真实代码(节选)
async getRunnableSession(id: string): Promise<AgentSessionRow> {
  const session = await this.repo.getSessionLifecycle(id)              // 带 taskArchivedAt
  if (!session) throw new AppError({ code: EyrieErrorCode.session.notFound })
  if (isSessionArchived(session)) throw new AppError({ code: EyrieErrorCode.session.archived })
  if (isTaskArchived({ archivedAt: session.taskArchivedAt })) {
    throw new AppError({ code: EyrieErrorCode.task.archived })         // 父 task 归档
  }
  return session
}

blob 路由对读和写做了不同的闸上传(POST)getRunnableSession——上传是新一轮 turn 输入,归档 session 在存盘前就被 409 拒;下载(GET/HEAD)只走 getSession 查存在性——归档 session 的行和磁盘 blob 在硬删前都还在,它时间线里的图片必须可读,若也按 runnable 闸会把这些既存引用错误地 409。两个动词的归属凭证由路由中间件统一校验。(routes/blob.ts + routes/uploads.tsUploadSessionService 因此同时暴露 getRunnableSessiongetSession。)而 agent.events 的存在性闸(assertSessionExists)用的是另一个读——getActiveSessionvisibleSessionWhere),归档 session 直接当 404:它的 runner 已拆,订阅上去只会永久挂在一个没人再 publish/close 的 fan-out key 上。

B.2写本身 archive-aware(不只在读时 assert)

光在读出来时检查归档不够——读和写之间有个窗口,一次并发归档可能在 assertTaskMutable 读完之后才落地,而归档不 bump version,所以乐观锁的版本号挡不住它。解法是把 mutableTaskWhere() 直接折进 UPDATE 的 where:归档一旦落地,这条 UPDATE 就匹配不到行,changes===0,再回读判定归档并报 task.archived

apps/daemon/src/services/tasks.ts · update() + assertTaskWriteApplied真实代码(节选)
const result = this.db.update(tasksTable).set({ /* ...fields... */ })
  // mutableTaskWhere() 让写本身 archive-aware:读后才落地、且没 bump version 的并发归档
  // 仍会让这条 UPDATE 匹配不到行
  .where(and(eq(tasksTable.id, id), eq(tasksTable.version, current.version), mutableTaskWhere()))
  .run()
this.assertTaskWriteApplied(id, input.expectedVersion, result)

// assertTaskWriteApplied:changes!==1 时回读最新行——
//   行没了 → task.notFound;isTaskArchived(latest) → task.archived;否则才是 staleVersion

move / batchMove 用同样的手法(batchMove 在事务里逐条写,任一条 changes!==1 就抛 task.archived 并整批回滚)。session 改标题在 services/sessions.ts 里同构:UPDATE 带 isNull(archived_at)changes!==1session.archived

B.3建 session 的 TOCTOU:守卫下沉到 insert 事务

这里要防一个真竞态。建 session 原本只有 tRPC 接线层做一次「父 task 是否归档」的预读,但预读和真正 insert 之间隔着 provider 校验和 cwd 解析,一次并发归档能从中插进来,结果在归档 task 下建出一个永远不可见、不可跑的孤儿 session。修法是把校验下沉进 repo 的 insert 事务:同一事务里用 mutableTaskWhere() 复查父 task,miss 就抛 task.archived,从根上消除 read→archive→insert 窗口。tRPC 层那道预读保留作为 fail-fast,但权威守卫在这。

apps/daemon/src/agent/drizzle-repository.ts · createSession真实代码(节选)
async createSession(data): Promise<AgentSessionRow> {
  this.db.transaction((tx) => {
    // 在 insert 事务里复查父 task 可变。调用方预读关不掉 read->archive->insert 窗口,权威守卫在这
    const task = tx.select({ id: tasksTable.id }).from(tasksTable)
      .where(and(eq(tasksTable.id, data.taskId), mutableTaskWhere())).get()
    if (!task) throw new AppError({ code: EyrieErrorCode.task.archived })
    tx.insert(agentSessionsTable).values({ /* ..., archivedAt: null */ }).run()
  })
  return this.mustGetSession(data.id)
}

B.4close 的 CAS 折叠

另一个易错点:closeSession 走的是 archive-blind 的读 + 一个只判 id+status 的 CAS。归档 teardown 会把 session 留在 Idle,恰好落在 close 的 CAS from 集里——于是 close 把一个已归档 session 翻成 Closed,而 unarchive 只清 archived_at、从不重置 status,错态永久复现。最终修法是给 transitionSessionrequireActive 选项,把 isNull(archived_at) 折进 CAS 谓词本身:归档若在读守卫和写之间落地,CAS 直接 miss 报 conflict,而不是改坏那条停泊的行。

apps/daemon/src/agent/drizzle-repository.ts · transitionSession真实代码(节选)
const where = options.requireActive
  ? and(eq(agentSessionsTable.id, id), inArray(agentSessionsTable.status, from),
        isNull(agentSessionsTable.archivedAt))   // 把归档判定折进 CAS
  : and(eq(agentSessionsTable.id, id), inArray(agentSessionsTable.status, from))

closeSessiontransitionSession(id, [Idle, Suspended, Closed], Closed, {}, { requireActive: true }) 调它。默认 requireActive=false 是有意保留的——归档 teardown 路径自己要合法地结算一个正在被归档的 session(terminateRun),那条不能被 archived_at 滤掉。

B.5占用:归档视作已消失

这是本 PR 最大的一次设计反转(详见 §9):归档 task 不再占用它引用的 status / label / repo。删一个 status / label / repo 前的「还有人用吗」计数,从「任意现存 task 行」改成「只算 active task」——做法是 inUse 计数 join 回 tasks 并加 activeTaskWhere()。一个只被归档 task 引用的 status,现在可以删。

apps/daemon/src/services/repos.ts · 删除前的 inUse 计数真实代码(节选)
const activeTaskRepos =
  this.db.select({ value: count() })
    .from(taskReposTable)
    .innerJoin(projectReposTable, eq(taskReposTable.projectRepoId, projectReposTable.id))
    .innerJoin(tasksTable, eq(taskReposTable.taskId, tasksTable.id))   // 新增 join
    .where(and(eq(projectReposTable.repoId, id), isNull(taskReposTable.deletedAt),
               activeTaskWhere()))                                     // 只算 active task
    .get()?.value ?? 0
if (activeTaskRepos > 0) throw new AppError({ /* repo.inUseByProject */ })

services/labels.ts(label 删除的 taskCount)和 services/projects.ts(status 删除的 activeTasks 计数)做了同样的改动。对称地,归档 task 上的关系写和删都被挡:createTaskRepo / attachTaskLabel 拒(防往隐藏卡上挂新 repo),deleteTaskRepo / detachTaskLabel 也拒(防改写隐藏卡的资源足迹,与 attach 守卫对称)。

排查路标 · 旅程 B
症状 从哪下手
归档 session 还能 startTurn / 上传 blob agent/service.ts getRunnableSessionroutes/blob.ts 是否调它
报错说 session.notFound 但 session 明明在(只是归档了) repo 层 claimRun / beginTurnrunnableSessionWhere 是 defense-in-depth,返回 notFound;对外契约由 service 层 getRunnableSession 给 archived(见 §13)
归档 task 下还能建出 session drizzle-repository.ts createSession 事务内的 mutableTaskWhere() 复查
归档后 session 状态被翻成 Closed agent/service.ts closeSession + transitionSessionrequireActive
只被归档卡用的 status/label/repo 删不掉 services/{repos,labels,projects}.ts 的 inUse 计数是否加了 activeTaskWhere()

8旅程 C:物理删除(→ deleted)

删除现在是不可逆的物理删 + 外键级联。难点不在删行本身(FK cascade 一条 DELETE 就清掉 tasks→sessions→runs/events),而在删行之前要先停掉活的 runner、删行之后要清磁盘,而且这中间有一个并发建 session 的窗口。

全景 · 涉及 3 个文件
编排
use-cases/tasks.ts · projects.ts
级联原语
db/cascade.ts
teardown + 清盘
agent/service.ts · upload-store.ts

C.1两遍枚举闭窗

不变式有两条:被删子树下不能有任何 session 残留着活的 runtime 或没清的磁盘目录;且被并发 reparent 移出子树的后代不能被误删。一遍枚举做不到——「预删快照」和「真正 DROP」之间隔着一串 await(teardown 是异步的),这个窗口里既可能新建 session,也可能有一次并发 tasks.update 把某个后代从子树里挪走(或挪进来)。所以是两遍:第一遍用含归档的 ref(归档 session 的行也要被 DROP,得清)趁行还在时停 runner;第二遍在 delete 事务内把整棵子树连同它的 session 一起重新走一遍,把 DROP 的范围钉死在「删除那一刻真正挂在这棵树下」的行上——挪出去的后代得以幸存,挪进来的会被删,窗口里新建的 session 仍被清。

apps/daemon/src/use-cases/tasks.ts · deleteTask真实代码(节选)
export async function deleteTask(deps, id): Promise<{ id: string }> {
  deps.tasks.getActiveRow(id)
  const subtreeIds = deps.db.transaction((tx) => collectTaskSubtreeIds(tx, id))
  // 第一遍:含归档的 ref,趁行还活着结算 runtime(terminate:false,行马上要 DROP)
  const refs = await deps.agentRepo.listSessionRefsByTasksIncludingArchived(subtreeIds)
  for (const ref of refs)
    await deps.agentSessions.teardownSessionRuntimeBestEffort(ref.id, { terminate: false })
  // 第二遍:在 delete 事务内重新走整棵子树 + 它的 session,把 DROP 钉死在「此刻」的行上
  const deletedSessionIds = deps.db.transaction((tx) => {
    const ids = collectTaskSubtreeIds(tx, id)              // 重新收集子树:挪出去的后代不再在内
    const sessionIds = collectSessionIdsUnderTasks(tx, ids)
    hardDeleteTaskTree(tx, { ids })
    return sessionIds
  })
  for (const sessionId of deletedSessionIds) {
    await deps.agentSessions.teardownSessionRuntimeBestEffort(sessionId, { terminate: false })
    await deps.agentSessions.purgeSessionUploads(sessionId)   // FK 只删 DB 行,磁盘目录单独清
  }
  return { id }
}

窗口在事务边界自然闭合:行一旦 DROP,createSessiontaskExists 探针就拒绝再往这棵已删子树建 session。deleteProjectuse-cases/projects.ts)是同构的镜像,只是用 listSessionRefsByProjectIncludingArchived + collectSessionIdsUnderProject

C.2删序与磁盘清理

task 子树的删序不能乱:parent_task_id 是自引用 FK 且是 NO ACTION,父还被子指着时删不掉。所以 hardDeleteTaskTree 把广度优先的 id 列反转,先删子。project 树则不需要——删 projects 行后全靠 FK cascade 递归下去,删序是引擎的事。

apps/daemon/src/db/cascade.ts · hardDeleteTaskTree真实代码(节选)
export function hardDeleteTaskTree(tx, input: { ids: string[] }): void {
  // ids 是广度优先(父在前);反转成先删子——parent_task_id 自引用 FK 是 NO ACTION,
  // 父还被子指着时删不掉。(project 树无需如此:删 projects 行后全靠 FK cascade 递归)
  for (const id of [...input.ids].reverse()) {
    tx.delete(tasksTable).where(eq(tasksTable.id, id)).run()
  }
}

磁盘清理在 upload-store.ts 新增的 purgeSessionUploads。它是 best-effort 的(DB 删除才是权威成功边界,rm 失败只能上抛给 caller 吞掉,绝不回滚已提交的删除),并且用和 resolveUploadRef 同样的方式做路径围栏:解析后必须落在 baseDir 内,否则拒——一个恶意/畸形的 sessionId 永远 rm 不到 cache root 之外,空 id 也删不掉整个 cache。

apps/daemon/src/services/upload-store.ts · purgeSessionUploads真实代码(节选)
export async function purgeSessionUploads(baseDir: string, sessionId: string): Promise<void> {
  const resolvedDir = resolve(join(baseDir, sessionId))
  // 防 id 越过 cache root(".."、绝对路径…);等于 baseDir 本身也拒,免得空 id 把整个 cache 删了
  if (!resolvedDir.startsWith(resolve(baseDir) + sep)) {
    throw new AppError({ code: EyrieErrorCode.validation.failed })
  }
  await rm(resolvedDir, { recursive: true, force: true })   // 缺目录是 no-op(force:true)
}

还有一个易漏的收尾:idle session(run 已结束、没有 live runner)被级联删时,runnerManager.dispose 会 early-return,但 teardownSessionRuntimeProviderSteps 里那句 broadcaster.unsubscribeAll 仍无条件跑,所以一个开着的 agent.events 订阅会收到干净的 end-of-stream,而不是对着一条被删的行永久挂起。单 session 硬删走 agent.deleteAgentService.deleteSession,同样是 teardown(terminate:false) → hardDeleteSessionpurgeSessionUploads

排查路标 · 旅程 C
症状 从哪下手
删 task 后冒出孤儿 runner / provider 进程 use-cases/tasks.ts deleteTask 的两遍枚举;第二遍在事务内 collectTaskSubtreeIds + collectSessionIdsUnderTasks
删 task 时把一个刚被 reparent 移走的后代也删了 use-cases/tasks.ts deleteTask 第二遍是否重新 collectTaskSubtreeIds(tx, id),而非复用预删快照 subtreeIds
删完后 cache 里残留 session 上传目录 agent/service.ts purgeSessionUploadsupload-store.ts 同名函数
删父 task 报 FK 约束错 cascade.ts hardDeleteTaskTree.reverse()(先删子)
删后某个 agent.events 订阅永久挂起 service.ts teardownSessionRuntimeProviderStepsunsubscribeAll
删 project 后订阅没收到 end-of-stream trpc/services.ts projects.deleteboardDeltas.closeTopic

9关键设计判断(为什么不是直觉做法)

下面几处是代码里不直觉、但有意为之的设计取舍——光看结果看不出为什么。每条 = 直觉/早期的做法 / 实际怎么做 + 为什么。

直觉 / 早期做法 实际做法 + 为什么
归档仍占用资源。archived 隐藏,但仍占着它引用的 status/label/repo——理由似乎是「用户可能反归档拉回重用,资源得给它留着」。占用计数用 anyTaskWhere()(含归档)。 归档不占用资源。「占用」会让一张所有视图都看不见的卡,以「active」名义挡住资源删除,把用户引向一个看不见的行的死胡同;于是归档定为纯软删(隐藏 = 等于不存在)。最终:repos/labels/projects 的 inUse 全用 activeTaskWhere()anyTaskWhere() 退到只服务 includeArchived 一处读。
session 级软删 + restore。给 session 一个可恢复的软删:delete 写标记、restore 清标记,读侧 isNull 隐藏。 task 级 archive + 物理 delete。「可恢复删除」这个中间态被判为多余——删一律不可逆,想留着不看走归档;而且归档应是 task 级(连同其下所有 session 一起停泊),单 session 归档无意义。那套软删机器被重定位成 archive 的底座,真正的 delete 改走既有 FK cascade。
deleted_at 列保留备用。task/session 留着 deleted_at 列「以后做回收站」。 三张表的 deleted_at 彻底删。task/session 全仓根本没有写入点(纯保留列),留着只会诱导误用;部分索引谓词收敛到 archived_at IS NULL。资源/关系表的 deleted_at 是另一条独立的「资源退役」轴,保留不动。
建 session 的归档守卫只在接线层预读。 守卫下沉到 repo 的 insert 事务。预读关不掉 read→archive→insert 的窗口,会在归档卡下建孤儿 session。改成事务内 mutableTaskWhere() 复查(见 §B.3),从根上消除竞态。
只拦「往归档卡挂资源」(attach),detach 放行。 attach 和 detach 两侧都拒。否则能改写一张隐藏卡的资源足迹;deleteTaskRepo / detachTaskLabel 现在 join 回 owning task 读归档态并拒,与 attach 守卫对称。
close 用普通 CAS(只判 id+status)。 CAS 折叠 isNull(archived_at)归档 teardown 把 session 留在 Idle,恰落进 close 的 from 集,close 会把归档 session 翻成 Closed,而恢复不重置 status → 错态永久;requireActive 把归档判定折进 CAS 根治(见 §B.4)。
blob 路由所有动词统一按一种闸(可运行)拦。 读和写分开闸。上传(POST)是新 turn 输入,按可运行拦(归档 409);下载(GET/HEAD)只查存在性——归档 session 的行和磁盘 blob 在硬删前都还在,时间线里的图片必须可读,按可运行拦会把这些既存引用错误地 409。

10心智模型补丁

删 task/session/project = 写一个 deleted_at 时间戳,行还在、能 restore。 删 = 物理删行 + FK 级联,不可逆、无回收站;想「留着不看」走 archive。
delete 的 ack 只带 { id },不再有 deletedAt 字段——前端别再读它。
每个 service 自己手写 isNull(deletedAt) 决定什么可见。 可见 / 可改 / 可跑 / 占用四类判定全部来自 lifecycle-predicates.ts,改语义改一处。
归档是「隐藏但仍占用资源」的半软删态。 归档 = 纯软删:隐藏 + 不占资源 + 可恢复,对系统等于「已消失,只它自己知道」。
只被归档 task 引用的 status/label/repo 可以删——这点 GitHub PR 描述写反了,以代码为准。
归档/恢复是 session 粒度,session 有独立的 delete/restore。 归档是 task 粒度,session 的 archived_at 永远继承自父 task;没有单 session 的 archive 动词(但有单 session 的物理 agent.delete)。
projects 和 tasks/sessions 一样有软删轴。 projects 没有 archived_at——只能硬删,不能归档;只有 task / session 在 archive 轴上。
守卫 = 在读出实体后 assert 一下归档态就够了。 关键写入把谓词折进 SQL 的 where / CAS 本身(mutableTaskWhere、requireActive),防读写之间的并发归档窗口。
对一个不存在 / 不可用的 session 操作,统一是 session.notFound。 归档态有专门的 task.archived / session.archived(409);前端可据此提示「先反归档」而不是「找不到」。

11新词表

状态机
archived_at task/session 上的归档时间戳列;非 null = 归档(停泊、隐藏、可恢复)。取代了旧的 deleted_at
active / archived / deleted 三态:可见可用 / 隐藏可恢复 / 物理消失。
谓词(lifecycle-predicates.ts)
activeTaskWhere / mutableTaskWhere 「未归档 task」的 SQL where;后者是前者的别名(可改 = active)。
visibleSessionWhere / runnableSessionWhere 「session 自身 + 父 task 都未归档」;两者是同一函数的别名。
anyTaskWhere 退化成 1 = 1,只给 includeArchived 读路径用(不是占用计数)。
仓库 / 服务
getSessionLifecycle 读 session 行 + 父 task 的 taskArchivedAt,供运行守卫分辨自身归档还是父归档。
getActiveSession vs getSession 前者过 visibleSessionWhere(归档当不存在,给 agent.events 存在性闸用);后者 archive-blind(给 teardown / 历史回放用)。
getRunnableSession service 层运行守卫;归档抛 session.archived / task.archived。blob 上传也走它。
teardownSessionRuntimeterminate true=归档(保 run 行,结算到终态);false=硬删(行马上 DROP,不结算)。
requireActive(transitionSession 选项) isNull(archived_at) 折进状态 CAS,防并发归档被覆盖。
SessionRef teardown 收集用的 {id, cwd} 轻量句柄。
purgeSessionUploads 删 session 后清它的磁盘上传目录(FK 只清 DB 行);best-effort + 路径围栏。
includeArchived task 列表的可选读标志,默认 false;唯一暴露归档项的读面。配合 label 过滤时,该读会额外要求 label 行本身未删(join labels + isNull(deletedAt)),否则一个只被归档卡用过、已被删的 label 仍能借 includeArchived 把任务查出来。

12测试与风险地图

近六成是测试,新增四个文件专钉新行为:cascade.test.ts(级联原语,纯事务)、lifecycle-predicates.test.ts(谓词分类)、task-lifecycle.test.ts(use-case + teardown 边界,含订阅关闭、窗口竞态、reparent-移出后代幸存)、service-teardown.test.ts(dispose 抛错时 fan-out key 仍经 finally 关闭)。route-contract.test.ts 暴涨 +400 余行钉对外契约。

有兜底(行为被测试钉死) 薄冰(无测试 / 已知遗留)
  • archive/unarchive 级联原子 + 幂等(cascade / task-lifecycle)
  • 硬删级联 + teardown + 清盘 + 窗口竞态(task-lifecycle / cascade / agent-service-methods)
  • 运行守卫两个错误码、父/自身归档(agent-service-methods / agent-repository)
  • 写守卫 update/move/batchMove 拒、unarchive 仍可(route-contract)
  • session 隐藏/404、改标题拒(route-contract)
  • 占用非占用:只被归档卡引用的 status/label/repo 可删(route-contract:373)
  • 级联删关闭 idle 订阅(task-lifecycle,task 与 project 两入口)
  • dispose 抛错时 fan-out key 仍经 finally 关闭(service-teardown)
  • 下载(GET/HEAD)按存在性放行归档 session 的既存 blob、上传(POST)按可运行拒(blob-route)
  • reparent 移出子树的后代在并发删除下幸存(task-lifecycle / route-contract)
  • best-effort teardown 吞 provider 故障 / 归档 settle 失败上抛(agent-service-methods)
  • 🟡startTurn-vs-delete 竞态(既存问题,未在本 PR 处理)getOrCreate 建 runner 前不回查 DB 行,删除若落在 beginTurn 与建 runner 之间的 await 缝里,会建出指向已删行的孤儿 runner。当前单用户桌面用法基本不可达;并发编排上线后值得再收。
  • project 级硬删只测了订阅关闭,没有 project 粒度的 teardown/purge 断言(task 粒度有)。
  • 无显式 migration drift / 「deleted_at 列已删」的 schema 测试(fixture 删字段是隐式依赖)。
  • best-effort 组合分支(dispose 抛 + settle 也抛)、purgeSessionUploads 的 rm IO 失败分支,均无单测。
合并/拉取前必办:本 PR 删了 deleted_at 列,不是数据保留迁移。dev 单基线工作流是重生成 0000_init.sql,拉取后必须重置本地 dev 数据库(删掉本地 DB 文件),否则旧 schema 会与新 baseline 漂移。上面那条 startTurn-vs-delete 竞态是已知的可接受残留,非阻断项。

13验收提示(别被这些吓到)

14覆盖声明

生产代码(~1,500 行 / apps/daemon/src + packages/{api,db})在 PR 分支逐文件全量精读,报告中每段代码均出自亲自 Read 后裁剪;before 态从 base 4fbeed4 侧确认。head 从 4a4d8c9 推进到 a762ad5 的两个后续修复 commit(fan-out close、blob 读闸 / reparent / 标签过滤),按其逐文件 diff 精读并已据此更新对应章节。测试层(~2,200 行 / 30+ 文件)独立精读后,关键行为结论已对照生产代码交叉核验。无生产代码抽样略读。报告自包含、阅后即弃、不维护、不作真相源。