#70 lifecycle-convergence:把 task/session/project 的「下线」收敛成一个状态机
figuretu/eyrie · 4fbeed4...a762ad5(base = merge-base,head = squash + 2 个修复 commit) ·
2026-06-28 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 把 task(看板卡片)、agent_session(绑在 task 下、带运行中 runner 子进程的 Agent 会话)、project
三类实体的「下线」语义,从一盘散沙收敛成一个状态机、一套谓词。终态只有三态,两个互斥的存储态加一个物理终态:active(archived_at IS NULL,可见可改可跑)、archived(archived_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 是生成物。
| 设计重心(要细读) | 可放心略过 |
|---|---|
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 并降格为「可恢复的隐藏」。
以前 · 散点过滤 + 软删轴
现在 · 一套谓词 + archive/物理删
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_at;projects 没有 archived_at——project 只能被硬删,不存在「归档 project」这个动作。session
的 archived_at 不是独立动词,永远由父 task 的归档级联写入。资源/关系表(labels、repos、project_statuses、task_repos…)保留各自的
deleted_at,那是一条独立的「资源退役」轴,本次不动。
// 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'),
仓库契约新增两个轻量类型,给跨资源编排和运行守卫用:
// 生命周期 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 这里的函数。注意三个细节——mutableTaskWhere 是
activeTaskWhere 的别名而非拷贝(避免两处谓词漂移);session 的可见/可运行要求 session 自身和父 task
都未归档(靠 join tasks);anyTaskWhere 退化成 1 = 1,只服务 includeArchived 一条读路径。
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 上。
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 怎么被停泊、以及为什么反归档不能从子节点做。
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 都不刷新)。
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
根本没存的新时间。
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)。
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.ts 的 tasks.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。这让前端能告诉用户「是这条会话被归档了」还是「整张卡被归档了」。
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.ts 的 UploadSessionService 因此同时暴露 getRunnableSession 和 getSession。)而
agent.events
的存在性闸(assertSessionExists)用的是另一个读——getActiveSession(visibleSessionWhere),归档
session 直接当 404:它的 runner 已拆,订阅上去只会永久挂在一个没人再 publish/close 的 fan-out key 上。
B.2写本身 archive-aware(不只在读时 assert)
光在读出来时检查归档不够——读和写之间有个窗口,一次并发归档可能在 assertTaskMutable 读完之后才落地,而归档不 bump
version,所以乐观锁的版本号挡不住它。解法是把 mutableTaskWhere() 直接折进 UPDATE 的 where:归档一旦落地,这条 UPDATE
就匹配不到行,changes===0,再回读判定归档并报 task.archived。
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!==1 报 session.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,但权威守卫在这。
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,错态永久复现。最终修法是给 transitionSession 加 requireActive 选项,把
isNull(archived_at) 折进 CAS 谓词本身:归档若在读守卫和写之间落地,CAS 直接 miss 报 conflict,而不是改坏那条停泊的行。
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))
closeSession 用
transitionSession(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,现在可以删。
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 getRunnableSession;routes/blob.ts 是否调它 |
| 报错说 session.notFound 但 session 明明在(只是归档了) | repo 层 claimRun / beginTurn 的 runnableSessionWhere 是
defense-in-depth,返回 notFound;对外契约由 service 层 getRunnableSession 给 archived(见 §13) |
| 归档 task 下还能建出 session | drizzle-repository.ts createSession 事务内的 mutableTaskWhere() 复查 |
| 归档后 session 状态被翻成 Closed | agent/service.ts closeSession + transitionSession 的
requireActive |
| 只被归档卡用的 status/label/repo 删不掉 | services/{repos,labels,projects}.ts 的 inUse 计数是否加了 activeTaskWhere() |
8旅程 C:物理删除(→ deleted)
删除现在是不可逆的物理删 + 外键级联。难点不在删行本身(FK cascade 一条 DELETE 就清掉 tasks→sessions→runs/events),而在删行之前要先停掉活的 runner、删行之后要清磁盘,而且这中间有一个并发建 session 的窗口。
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 仍被清。
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,createSession 的 taskExists 探针就拒绝再往这棵已删子树建
session。deleteProject(use-cases/projects.ts)是同构的镜像,只是用
listSessionRefsByProjectIncludingArchived + collectSessionIdsUnderProject。
C.2删序与磁盘清理
task 子树的删序不能乱:parent_task_id 是自引用 FK 且是 NO ACTION,父还被子指着时删不掉。所以
hardDeleteTaskTree 把广度优先的 id 列反转,先删子。project 树则不需要——删 projects 行后全靠 FK cascade
递归下去,删序是引擎的事。
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。
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.delete →
AgentService.deleteSession,同样是 teardown(terminate:false) →
hardDeleteSession → purgeSessionUploads。
排查路标 · 旅程 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 purgeSessionUploads → upload-store.ts 同名函数 |
| 删父 task 报 FK 约束错 | cascade.ts hardDeleteTaskTree 的 .reverse()(先删子) |
| 删后某个 agent.events 订阅永久挂起 | service.ts teardownSessionRuntimeProviderSteps 的 unsubscribeAll
|
| 删 project 后订阅没收到 end-of-stream | trpc/services.ts projects.delete 的 boardDeltas.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心智模型补丁
{ id },不再有 deletedAt 字段——前端别再读它。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 上传也走它。 |
teardownSessionRuntime 的 terminate |
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 余行钉对外契约。
| 有兜底(行为被测试钉死) | 薄冰(无测试 / 已知遗留) |
|---|---|
|
|
0000_init.sql,拉取后必须重置本地 dev 数据库(删掉本地 DB 文件),否则旧 schema 会与新 baseline 漂移。上面那条
startTurn-vs-delete 竞态是已知的可接受残留,非阻断项。
13验收提示(别被这些吓到)
anyTaskWhere()返回1 = 1看着像废代码——它不是死代码,是 includeArchived 列表读的唯一谓词(占用反转后从计数路径退到这里)。SessionRef.cwd装了但今天只消费id——有意为之,留给将来目录级 teardown 免二次查。TaskService.getActiveRow名叫 active 却是 archive-blind(只eq(id),不滤归档)——故意的,让 update/unarchive/delete 仍能解析归档行;归档拒绝发生在mutableTaskWhere/assertTaskMutable,不在这个读。- 同一条归档 session,service 层报
session.archived(409)、repo 层claimRun/beginTurn报session.notFound——不是 bug。repo 守卫是 defense-in-depth,service 守卫拥有对外契约。 runnableSessionWhere与visibleSessionWhere是同一函数的别名——不是漏抄;当前测试种子也因此无法区分二者。- 两处注释与最终设计不同步:
service.ts的createSession注释仍写「missing/soft-deleted task」、tasks.ts的nextPosition注释写「archived tasks that still occupy resources」——都是 occupancy=yes 时代的残留措辞,行为已是非占用,别被注释误导。 - 分支 = 1 个 squash 的主 commit + 2 个后续修复 commit——主体的分阶段历史看不到,但逐文件 diff 完整;两个修复 commit(fan-out close、blob 读闸 / reparent / 标签过滤)可单独看。
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+
文件)独立精读后,关键行为结论已对照生产代码交叉核验。无生产代码抽样略读。报告自包含、阅后即弃、不维护、不作真相源。