PR #66 minimal agent session loop:把「看板占位 session」换成「真能跑 agent 的统一会话」

figuretu/eyrie · main...feat/agent-session-loop-e2e(base 6fa33a1) · 2026-06-14 · 作者 ponsde · 自包含,读完即弃

6 commits
51 文件
+2455 / −3710
净 −1457
~35% 是测试代码
类型:功能 + 表统一 + 迁移压缩

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

1TL;DR

这个 PR 把 issue #61 的「最小端到端 agent 会话回路」落了地:开一个会话 → 跑一轮(对真实 claude/codex 二进制)→ 流式收事件 → 关掉,并支持「关掉的会话再发一条消息就自动重开」。

它顺手做了一次模型统一:删掉了原先那张挂在 project 上、当看板卡片用的占位 sessions 表,所有会话读写改走早已存在的 agent_sessions 表。会话不再自带 project,而是绑定到一个 task,project 由 task 反推。

同时立了一条工作目录契约:task 在创建时绑定一个绝对路径 workingDir,会话复用它当 cwd。相对路径在两道关卡(wire schema + daemon resolve)被拦死,避免悄悄绑到 daemon 自己的进程目录。

净 −1457 行的「缩水」是假象——删的大头是被替换掉的占位 session 服务/CLI,加上一次迁移压缩(3 个迁移合并成单个 0000_init 基线,项目还没发布过 schema,无需保留历史)。真正承载设计的新代码集中在 daemon 的 agent 域、API 契约层和一大套测试。

2变更地图(称重)

按变更行数(增+删)分布,最大的一块是迁移目录,但它几乎全是机械压缩,不需要细读;真正要看的设计承载在 daemon 的 srctests

db/migrations
2766 行 · 机械压缩
daemon/tests
1728 行 · 设计重心
daemon/src
754 行 · 设计重心
apps/cli
632 行(src+tests)
packages/api
188 行 · 契约
packages/db/src
72 行 · schema
设计重心(要细读)可放心略过(机械)
agent/service.ts 会话生命周期编排(+75/−12)
agent/drizzle-repository.ts CAS 与原子拆除(+62/−9)
services/sessions.ts 从「看板表」降级为「元数据代理」(+58/−207)
services/repos.ts workingDir 落地校验(+31/−2)
schemas.ts workingDirSchema + 会话契约改写(+22/−12)
db/src/index.tssessions 表、agent_sessionstask_id(+8/−64)
两个真机 e2e 套件 + narration(974 行新增)
migrations/meta/0001_snapshot.json(−2442)
migrations/meta/0000_snapshot.json 重生成(+47/−218)
三个迁移合并成 0000_init.sql,journal 从 3 条变 1 条
DTO 字段一一映射(name→title、删 version/labels
错误码删除 sessionLabel.* 族(纯删除)
session_labels 关系表整张删除

测试占比 ~35%(2147 / 6165 行)。但这个数字被机械迁移稀释了——把迁移那 2766 行机械churn 摘掉,剩下的真实改动里测试占到约 三分之二。换句话说:这是一个测试比实现还重的 PR,两个真机 e2e 套件本身就是核心交付物。全量单测 1105 passed | 14 skipped 绿。

3架构一图流

变化的不是进程拓扑,而是「会话」这个概念归属于谁。以前有两张会话表各管一摊;现在只剩一张,且生命周期方法从 tRPC 的 agent 命名空间统一驱动。

以前 · 两张表,会话是看板卡片

CLI / tRPC
create / update / delete
sessions 表
(挂 project)
session_labels
贴标签
labels
agent_sessions 表
无入口(没人 createSession)
(闲置)

现在 · 一张表,会话能真跑 agent

CLI / tRPC
agent.*
createSession / startTurn / interrupt / close
AgentService
AgentService
CAS 状态机 + 原子拆除
agent_sessions
(绑 task)
RunnerManager
spawn / 流事件 / dispose
真实 claude/codex

注意一处不直观的接线:CLI 的 session create 仍然敲 client.sessions.create,但 appRouter 里这个 procedure 现在转调 ctx.services.agent.createSession——旧的路由名保住了,背后的服务换成了新的 AgentService。start-turn / close 则直接走 client.agent.*

packages/api/src/trpc.tssessionsRouter(真实代码节选)
  create: publicProcedure
    .input(createSessionSchema)
    // 路由名没动,但落点从 sessions.create 改成了 agent.createSession
    .mutation(({ ctx, input }) => callService(() => ctx.services.agent.createSession(input))),
  update: publicProcedure.input(updateSessionInputSchema).mutation(({ ctx, input }) => {
    const { sessionId, ...body } = input
    return callService(() => ctx.services.sessions.update(sessionId, body))  // update 仍走旧 service
  }),
  // delete 整个删掉了——会话不再「删除」,只「关闭」

4数据与状态先行

先把后面旅程要用到的「形状」摆出来,只看结构不讲行为。

agent_sessions 表:长出 task_iddeleted_at

会话现在必须挂在一个 task 上(外键 cascade,task 硬删则会话连带删)。project 不再存在会话上——要 project 时通过 task_id join 出来。同时补了软删列 deleted_at 和一条按 task 过滤的索引。

packages/db/src/index.tsagentSessionsTable(diff 关键行)
export const agentSessionsTable = sqliteTable('agent_sessions', {
  id: text('id').primaryKey(),
  taskId: text('task_id')                              // 新增:会话的唯一父亲
    .notNull()
    .references(() => tasksTable.id, { onDelete: 'cascade' }),
  cwd: text('cwd').notNull(),                          // 运行时工作目录(建会话时从 task 派生)
  providerId: text('provider_id').notNull() ...,
  status: text('status').notNull() ...,               // idle / active / suspended / closed / error
  model: text('model'),                               // 选中的 provider model
  sessionConfigJson: text('session_config_json'),     // 校验后的 provider 会话配置
  deletedAt: integer('deleted_at'),                   // 新增:软删标记(本 PR 只读不写)
})

② 会话状态枚举:从「看板生命周期」换成「运行时状态」

旧表的 statusKeyplanned / active / closed(看板视角);新表的 status 是运行时机器状态,且没有 version 乐观锁了——并发安全改由数据库 CAS 兜(见 §5.1)。

idle会话就绪,等下一轮输入
active正在跑一轮(有一个 Running 的 run)
suspendeddaemon 重启时把残留的 active 批量挂起
closed用户关掉了;但可被下一轮重新唤醒(closed-resume)
error崩溃留下的终态

workingDirSchema:wire 边界的绝对路径正则

新增的共享 schema,createTaskcreateTaskRepo 都用它。只放行「/ 开头的绝对路径」「~ / ~/ home 前缀」「C:\ 盘符」,其余相对路径在这里就被拒成 validation.failed

packages/api/src/schemas.tsworkingDirSchema(真实代码)
// 会话复用作 cwd 的 task 工作目录(决策 A1=b):绝对主机路径,或 daemon 会展开的 ~/~ home 路径。
// 其余相对输入在这里拒掉——daemon 会拿它去 resolve 自己的进程 cwd,悄悄绑错目录。
const workingDirSchema = z
  .string().trim().min(1).max(4000)
  .refine((value) => /^(\/|~(?:$|[/\\])|[A-Za-z]:[/\\])/.test(value), {
    message: 'workingDir must be an absolute path',
  })

normalizeInput:一轮输入的归一化形状

一轮的输入是 { text, parts? }。当 parts(文本/图片片段数组)非空时它说了算text 会从 parts 重建,图片渲染成 [image: mime] 占位,保证「持久化/广播的文本」和「真正喂给 runner 的 parts」一致。

apps/daemon/src/agent/service.tsnormalizeInput(真实代码节选)
function normalizeInput(input: AgentInput): AgentInput {
  // parts 缺失或为空时不具权威性,原样透传纯文本
  if (!input.parts || input.parts.length === 0) return input
  for (const part of input.parts) {
    if (part.type !== 'text' && part.type !== 'image') {   // 未知片段类型 = 客户端错误
      throw new AppError({ code: EyrieErrorCode.validation.failed })
    }
  }
  const text = input.parts
    .map((part) => (part.type === 'text' ? part.text : `[image: ${part.mimeType}]`))
    .join('\n')
  return { text, parts: input.parts }
}

5底座:四个共用机制

三条旅程都踩在这四块地基上。先把地基讲透,旅程里就只讲各自特有的逻辑。

5.1CAS 状态机:用一条 SQL 的 WHERE 抢占状态

会话没有 version 乐观锁了,并发安全靠「compare-and-swap」:更新时在 WHERE 里限定「当前状态必须在某个允许集合内」,更新成功(影响行数 > 0)才算抢到。两个请求同时想开一轮,只有一个的 CAS 会命中。

开一轮的 CAS 在 beginTurn 里,整段裹在一个事务:先确认会话可启动(startable 集合 = idle / suspended / closed),再确认没有别的 Running run,然后插 run + 把会话翻成 active。这里把 closed 放进 startable,正是「关掉的会话能被下一轮重新唤醒」的实现点。

apps/daemon/src/agent/drizzle-repository.tsbeginTurn(真实代码节选)
async beginTurn(sessionId: string, input: BeginTurnInput): Promise<AgentRunRow> {
  return this.db.transaction((tx) => {
    const session = tx.select({ session: agentSessionsTable })
      .from(agentSessionsTable)
      .innerJoin(tasksTable, eq(agentSessionsTable.taskId, tasksTable.id))   // 顺带挡掉软删的 task
      .where(and(eq(agentSessionsTable.id, sessionId),
                 isNull(agentSessionsTable.deletedAt), isNull(tasksTable.deletedAt)))
      .all()[0]?.session
    if (!session) throw new AppError({ code: EyrieErrorCode.session.notFound })

    const startable: readonly string[] = [
      SessionStatus.Idle, SessionStatus.Suspended, SessionStatus.Closed,   // closed 在内 = closed-resume
    ]
    if (!startable.includes(session.status)) {
      throw new AppError({ code: EyrieErrorCode.resource.conflict })        // 409:会话此刻不可启动
    }
    const activeRun = tx.select().from(agentRunsTable)
      .where(and(eq(agentRunsTable.sessionId, sessionId), eq(agentRunsTable.status, RunStatus.Running)))
      .all()[0]
    if (activeRun) throw new AppError({ code: EyrieErrorCode.resource.conflict })   // 单活跃 run 不变式

    tx.insert(agentRunsTable).values({ id: input.id, sessionId, kind: RunKind.Turn,
                                       status: RunStatus.Running, ... }).run()
    tx.update(agentSessionsTable)
      .set({ lastRunId: input.id, status: SessionStatus.Active, ... })       // 同一事务里翻成 active
      .where(eq(agentSessionsTable.id, sessionId)).run()
    ...
  })
}
为什么不是直觉做法:直觉是「先建好 runner(启动子进程),再标记会话忙」。这里反过来——先在存储里 CAS 抢占,再建 runner(见旅程 B)。因为 CAS 拒绝是常态(会话可能是 error/active),先建 runner 会在拒绝时漏掉一个已 spawn 的子进程。

5.2原子拆除:terminateRun 把 CAS 当总闸

一轮的收尾(正常结束、被打断、启动失败)统一收口到 terminateRun。它的精髓是「整段拆除门控在一次 CAS 上」:只有当这次调用真的把 run 从 Running 翻走,后续的「取消挂起请求 + 把会话翻回 idle」才执行;否则一行不碰。

这道闸防的是一种竞态:interrupt 在 await 期间,run.completed 可能已经从另一条路把 run 终结了,并故意保留了某个 Resolving 的审批行。如果 terminateRun 不看 CAS 结果硬干,就会把会话状态、挂起行全部清掉,踩坏那条路的成果——而且更晚的一轮可能已经接管了会话。

apps/daemon/src/agent/drizzle-repository.tsterminateRun(真实代码节选)
async terminateRun(runId: string, reason: RunTerminationReason): Promise<void> {
  this.db.transaction((tx) => {
    const run = tx.select().from(agentRunsTable).where(eq(agentRunsTable.id, runId)).all()[0]
    if (!run) return                                  // run 不存在 = no-op
    const target = runTerminationTarget(reason)        // startTurnFailed→idle, interrupted→idle, crashed→error
    const terminated =
      tx.update(agentRunsTable)
        .set({ status: target.runTo, completedAt: now })
        .where(and(eq(agentRunsTable.id, runId), eq(agentRunsTable.status, RunStatus.Running)))
        .run().changes > 0                              // ← 总闸:只有这次真的翻走了 Running 才往下走
    if (!terminated) return                            // CAS 没命中 = 别人已终结,一行不碰

    cancelRunApprovals(tx, runId, [ApprovalStatus.Pending, ApprovalStatus.Resolving])
    cancelRunInputRequests(tx, runId, [InputRequestStatus.Pending, InputRequestStatus.Resolving])
    tx.update(agentSessionsTable)
      .set({ status: target.sessionTo, ... })
      .where(and(eq(agentSessionsTable.id, run.sessionId),
                 eq(agentSessionsTable.status, SessionStatus.Active)))   // 只在还 active 时翻,不抢新一轮
      .run()
  })
}

5.3workingDir 双层契约:wire 拦一次,daemon 再拦一次

同一个 workingDir 要过两道关,因为两道关防的不是同一件事。

第一道(wire / §4③ 的 workingDirSchema:跨平台地拒掉「明显的相对路径」。它放行 Windows 盘符 C:\ 和反斜杠 home——因为 schema 是跨平台共享的,不能假设客户端在 POSIX。

第二道(daemon / assertAbsoluteWorkingDir:就因为第一道放行了 C:\,POSIX daemon 上 resolve("C:\\…") 会把它当 cwd 相对路径,悄悄绑到 daemon 自己的工作目录。所以落地前用 Node 的 isAbsolute本机语义再拦一次(home 前缀放过,留给下游展开)。

apps/daemon/src/services/repos.tsassertAbsoluteWorkingDir + createTaskRepo(真实代码节选)
// wire schema 接受跨平台绝对形式(Windows C:\ 盘符、反斜杠 ~\ home),但 POSIX daemon 上
// resolve() 会把它们当 cwd 相对、悄悄绑到 daemon 工作目录。leading ~//~ 下游会展开成 home,放行。
function assertAbsoluteWorkingDir(workingDir: string): string {
  const isHome = workingDir === '~' || workingDir.startsWith('~/') || workingDir.startsWith(`~${sep}`)
  if (!isHome && !isAbsolute(workingDir)) {
    throw new AppError({ code: EyrieErrorCode.validation.failed })   // 第二道关
  }
  return workingDir
}

createTaskRepo(taskId, projectRepoId, workingDir?) {
  ...
  const resolvedWorkingDir = workingDir
    ? resolveWithinRootsSync(assertAbsoluteWorkingDir(workingDir), this.filesystemRoots, this.homeDir)
    : null
  const relation = { ..., workingDir: resolvedWorkingDir }   // 旧字段名是 worktreePath,本 PR 改名 workingDir
  this.db.insert(taskReposTable).values(relation).run()
}

落地点也变了:task_repos 表的 worktree_path 列改名 working_dir,语义从「将来 worktree 供给填的路径」变成「现在就绑定、会话直接复用的 cwd」。

5.4新增 provider 的标准步骤

本 PR 把内置 Claude provider 正式接进注册表。ProviderModule 是「一个 provider 自报家门」的契约对象:怎么造实例(factory)、首次启动时往库里塞什么种子行(builtinSeed)、有哪些静态 model 和会话配置字段。

apps/daemon/src/agent/providers/claude/index.ts + registry.tsprovider 注册(真实代码节选)
// 1) 在 provider 包里导出一个 module 描述符
export const claudeProviderModule: ProviderModule = {
  kind: 'claude-code',
  factory: () => new ClaudeProvider(),
  builtinSeed: { id: ..., kind: ..., transport: ..., name: ..., builtin: true,
                 configJson: JSON.stringify({ executablePath: 'claude' }) },
  models: getClaudeAvailabilityModelOptions(),
  sessionConfig: getClaudeSessionConfigFields(),
}

// 2) 在 registry 把它加进总清单(codex 早就在了,这次补上 claude)
export const PROVIDER_MODULES: ProviderModule[] = [claudeProviderModule, codexProviderModule]
新增一个 provider 的配方:① 在 providers/<name>/ 导出一个 ProviderModule(factory + builtinSeed + models + sessionConfig);② 把它 push 进 registry.tsPROVIDER_MODULES;③ 其余(种子落库、可用性探测、会话配置校验)由 registry / AgentService 自动接管,无需改业务代码。

6旅程 A:开一个会话

从「用户敲 session create」走到「agent_sessions 里多一行 idle 会话」。走通这条,你就知道一个会话凭什么能创建、cwd 从哪来、四种失败各报什么码。

全景 · 涉及 5 个文件
CLI
commands/session.ts
appRouter
trpc.ts
wireServices 四层 fail-fast
agent/service.ts
cwd 派生
drizzle-repository.ts
insert agent_sessions

A.1入口:CLI 的 create 落到 agent.createSession

CLI 子命令把 --taskId --providerId --title --model --sessionConfig 收齐,sessionConfig 是 JSON 字符串、就地 parseJsonObject 解析。它调的是 client.sessions.create——但如 §3 所述,这个 procedure 已经转接到 AgentService。

apps/cli/src/commands/session.tscreate 子命令(真实代码节选)
run: (client) =>
  client.sessions.create.mutate({
    taskId: args.taskId,
    providerId: args.providerId,
    title: args.title,
    model: args.model,
    sessionConfig: parseJsonObject(args.sessionConfig),   // 字符串 → 对象
  }),
human: (session) => consola.success(`${session.id}  ${session.status}`),  // 打印 id + idle

A.2四层 fail-fast:先验证,最后才插行

核心在 AgentService.createSession。它按顺序四道闸,每道闸对应一个明确的错误码,全过了才插入——绝不留下「插了一半」的孤儿会话。

apps/daemon/src/agent/service.tscreateSession(真实代码节选)
async createSession(input): Promise<AgentSessionRow> {
  await this.registry.getProvider(input.providerId)               ① provider 不存在 → provider.notFound
  // getTaskWorkingDir 对「task 不存在」和「task 没有单一工作目录」都返回 null,
  // 所以这里先单独探一次 task 存在性,让打错的 id 报 notFound(404),而非伪装成可恢复的 noWorkingDir(409)。
  if (!(await this.repo.taskExists(input.taskId))) {
    throw new AppError({ code: EyrieErrorCode.task.notFound })    ② task 不存在 → task.notFound(404)
  }
  const cwd = await this.repo.getTaskWorkingDir(input.taskId)
  if (!cwd) throw new AppError({ code: EyrieErrorCode.task.noWorkingDir })  ③ 没单一 cwd → noWorkingDir(409)
  const sessionConfig = await this.validateConfig(input.providerId, input.sessionConfig ?? {})  ④ 配置不合 schema → validation.failed
  return this.repo.createSession({
    id: createId(), taskId: input.taskId, cwd, providerId: input.providerId,
    title: input.title ?? null, status: SessionStatus.Idle,
    model: input.model ?? null,
    sessionConfigJson: Object.keys(sessionConfig).length > 0 ? JSON.stringify(sessionConfig) : null,
  })
}

第②③道闸为什么要拆开?因为 cwd 派生这一步「查不到」有两种完全不同的成因——id 打错了(不可恢复,404)和 task 在但还没绑工作目录(绑一个就能继续,409)。合成一个码会让前端无法区分该提示「换个任务」还是「去绑目录」。

A.3cwd 从哪来:task 必须恰好有一个工作目录

getTaskWorkingDirtask_repos 捞这个 task 的所有 working_dir恰好一个才返回它;零个或多于一个都返回 null(落回上面的 noWorkingDir)。

apps/daemon/src/agent/drizzle-repository.tsgetTaskWorkingDir(真实代码节选)
const dirs = rows.flatMap((row) => (row.workingDir ? [row.workingDir] : []))
if (dirs.length !== 1) return null   // 0 个或 ≥2 个都视作「没有可用的单一 cwd」
return dirs[0] ?? null

工作目录是在 task 创建时绑定的:createTask 的 use-case 把 workingDir 只挂到第一个 repo 关系上(其余传 undefined),所以「恰好一个」是设计意图,不是巧合。

apps/daemon/src/use-cases/tasks.tscreateTask(真实代码节选)
projectRepoIds.forEach((projectRepoId, index) => {
  deps.repos.createTaskRepo(task.id, projectRepoId, index === 0 ? workingDir : undefined)  // 只有第 0 个 repo 拿 workingDir
})
排查路标 · 旅程 A
症状从哪下手
建会话报 404 task.notFoundagent/service.tscreateSession 第②闸 / repo.taskExists(task id 打错或被软删)
建会话报 409 noWorkingDirdrizzle-repository.tsgetTaskWorkingDirdirs.length !== 1(task 没绑或绑了多个 working_dir)
cwd 绑成了 daemon 自己的目录services/repos.tsassertAbsoluteWorkingDir 是否被绕过;检查传入是不是相对路径
sessionConfig 被拒agent/service.tsvalidateConfigsession-config.tsvalidateSessionConfig
会话建出来 project 不对project 是 join 出来的,查 task.projectId,不在 agent_sessions

7旅程 B:跑一轮

从「start-turn」走到「真实 claude 流出 token、run 回到 idle」。这条旅程的设计眼在顺序:先抢占、后造 runner、失败就拆。

全景 · 涉及 4 个文件
CLI start-turn
commands/session.ts
agent.startTurn
trpc.ts
CAS 抢占
service.ts → beginTurn
造 runner
RunnerManager
流事件
真实 claude
tail 订阅

B.1抢占在前,runner 在后

这是全 PR 最关键的顺序决定。startTurn beginTurn(§5.1 的 CAS 事务)拿到 run, getOrCreate 造 runner。万一造 runner 或 provider 启动失败,catchterminateRun 把这一轮拆干净,会话回 idle。

apps/daemon/src/agent/service.tsstartTurn(真实代码节选)
async startTurn(sessionId, input): Promise<AgentRunRow> {
  const session = await this.getSession(sessionId)
  const normalized = normalizeInput(input)
  // 先在存储里认领这一轮。beginTurn 的 CAS 会拒绝不可启动的会话(如 error/active);
  // 若先造 runner,CAS 拒绝时就漏掉了那个句柄——对启动即 spawn 的 provider,更是漏掉一个活子进程。
  const run = await this.repo.beginTurn(sessionId, { id: createId(), inputText: normalized.text, startedAt: Date.now() })
  try {
    const handle = await this.runnerManager.getOrCreate(session, this.registry, this.repo, this.broadcaster)
    handle.sink.setCurrentRun(run.id)
    await handle.runner.startTurn(normalized)         // 真正驱动 claude/codex 子进程
  } catch (err) {
    await this.repo.terminateRun(run.id, 'startTurnFailed')   // 拆这一轮:run→failed, 会话→idle, 取消挂起请求
    throw err
  }
  return run
}

B.2事件怎么流回来:subscribe + tail

run 是异步终结的——claude 子进程一边流 token,sink 一边把事件持久化并广播。要看进度就订阅。CLI 的 tail 直接走 client.agent.events.subscribe,并能在本地停:收满 --take N 条,或撞到 run.completed--untilRunCompleted)。

apps/cli/src/commands/session.tstail / isRunCompletedEvent(真实代码节选)
const subscription = trpc?.client.agent.events.subscribe(
  { sessionId: args.id, ...(args.lastEventId ? { lastEventId: args.lastEventId } : {}) },
  { onData(item) {
      count += 1
      emit({ mode, json: item, human: (event) => consola.info(JSON.stringify(event)) })
      if (take !== undefined && count >= take) { subscription?.unsubscribe(); resolve(); return }
      if (args.untilRunCompleted === true && isRunCompletedEvent(item)) { subscription?.unsubscribe(); resolve() }
    }, onError: reject })

// 事件 payload 在 CLI 边界故意还没类型化(event DTO 未落地),只手工拆信封到 data.event.type
function isRunCompletedEvent(item): boolean {
  const data = (item as { data?: unknown }).data
  const event = data && typeof data === 'object' ? (data as { event?: unknown }).event : undefined
  return Boolean(event && typeof event === 'object' && 'type' in event && event.type === 'run.completed')
}

B.3旧 startTurn vs 新 startTurn

对照一下「同一个开一轮的动作」在 base 侧和现在的差别,关键差异是抢占与造 runner 的先后。

以前(base)
假设 runner 已存在,runnerManager.get
拿不到就直接 409
beginTurn 记一轮
setCurrentRun 再驱动(先用后验)
现在(pr-66)
beginTurn 做 CAS 抢占
抢不到(error/已有 active run)→ 409,没造任何 runner
抢到才 getOrCreate 造 runner
造失败 → terminateRun 拆轮回 idle
排查路标 · 旅程 B
症状从哪下手
start-turn 报 409 conflictdrizzle-repository.tsbeginTurnstartable 判定或「已有 Running run」分支
turn 失败后会话卡在 activeservice.tsstartTurncatch 是否走到 terminateRun('startTurnFailed')
怀疑有孤儿子进程service.ts:确认 CAS 在 getOrCreate 之前;RunnerManager.getOrCreate 的 spawn 时机
tail 收不到 run.completedcommands/session.tsisRunCompletedEvent 的信封拆解(data.event.type);订阅是否带对 sessionId
回复文本是空的但 run 显示 completedmessage.completed/message.delta 事件流;e2e 专门断言文本非空正是防这个

8旅程 C:关掉再开

从「close」走到「会话变 closed」,再从「对 closed 会话又 start-turn」走到「它自动复活」。这条旅程证明「关闭」不是终点。

全景 · 涉及 3 个文件
CLI close
commands/session.ts
agent.close
trpc.ts
interrupt→dispose→CAS
service.ts
下一轮 beginTurn 复活

C.1关闭三步:打断、销毁、CAS 翻 closed

closeSession 严格三步:先 interruptCurrentRun 终结活跃 run(如果有),再 dispose 销毁 runner 子进程,最后才 CAS 把会话翻 closed。前两步无论后面 CAS 成不成都已经做完——所以「CAS 没命中」也不会留下活子进程。

apps/daemon/src/agent/service.tscloseSession(真实代码全文)
async closeSession(sessionId: string): Promise<AgentSessionRow> {
  await this.getSession(sessionId)                    // 不存在 → session.notFound(404)
  await this.interruptCurrentRun(sessionId)           // 第 1 步:有活跃 run 就打断并 terminateRun
  await this.runnerManager.dispose(sessionId)         // 第 2 步:销毁 runner 子进程(先做,与 CAS 解耦)
  const closed = await this.repo.transitionSession(
    sessionId,
    [SessionStatus.Idle, SessionStatus.Suspended, SessionStatus.Closed],   // 第 3 步:CAS 翻 closed
    SessionStatus.Closed,
  )
  if (!closed) throw new AppError({ code: EyrieErrorCode.resource.conflict })
  return this.getSession(sessionId)
}

顺带看 interruptCurrentRun 的一个刻意选择:对未知 sessionId 报 404,而不是静默 { ok: true }——一个针对过期 id 的「停止」请求不该被当成成功。但「会话存在、只是没有活跃 run」仍然干净返回。

apps/daemon/src/agent/service.tsinterruptCurrentRun(真实代码节选)
async interruptCurrentRun(sessionId: string): Promise<void> {
  await this.getSession(sessionId)                    // 陌生 id → 404,而非伪装成功的 no-op
  const run = await this.repo.getActiveRun(sessionId)
  if (!run) return                                    // 存在但 idle:干净返回
  const handle = this.runnerManager.get(sessionId)
  if (handle) { try { await handle.runner.interrupt() } catch { /* best-effort,存储 CAS 才是权威 */ } }
  await this.repo.terminateRun(run.id, 'interrupted')  // 原子拆除:run→interrupted, 会话→idle
}

C.2复活:closed 在 startable 集合里

这里没有新代码——「关掉再开」恰恰是 §5.1 那一行 SessionStatus.Closed 待在 beginTurnstartable 集合里的直接后果。对 closed 会话发新一轮,CAS 命中,会话从 closed 翻回 active,照常跑。e2e 把这条路完整走了一遍。

apps/daemon/tests/session-loop-e2e.test.ts真机 closed-resume 断言(真实代码节选)
const closed = await service.closeSession(session.id)
expect(closed.status).toBe(SessionStatus.Closed)    // 关掉了

// closed-resume:对刚关掉的会话再发一条,它自动复活并跑到完成
const second = await narratedTurn(scenario, { service, repo, sessionId: session.id },
  'Reply with exactly: hello again.', { ... })
expect(second.status).toBe(RunStatus.Completed)
expect(second.text.toLowerCase()).toContain('hello again')              // 复活后真有模型回复
expect((await repo.getSession(session.id))?.status).toBe(SessionStatus.Idle)
排查路标 · 旅程 C
症状从哪下手
close 报 409 conflictservice.tscloseSessiontransitionSession CAS(会话当时是 active/error,不在允许集合)
关了但子进程还在service.tsrunnerManager.dispose 是否在 CAS 之前执行;RunnerManager.dispose
对陌生会话停止却返回成功service.tsinterruptCurrentRun 开头的 getSession 是否还在
closed 会话发消息却起不来drizzle-repository.tsbeginTurnstartable 是否仍含 SessionStatus.Closed
error 状态的会话关不掉已知限制:error 不在 close 的允许集合里(见 §11 薄冰 / PR 自述待办)

9心智模型补丁

读完这个 PR,对项目的几处既有认知要改:

会话挂在 project 上,是张能贴标签、有 version 的看板卡片(sessions 表) 会话挂在 task 上,是 agent 执行上下文(agent_sessions 表);project 由 task 反推,标签/版本都没了
想按 project 列会话?走 SessionService.list,它 join tasks 后用 tasks.projectId 过滤,而不是会话自带的列。
SessionService 是会话的主业务服务(create/update/delete + 标签关系) SessionService 只剩只读 + 改 title;真正的 create/生命周期搬到了 AgentService
找「会话怎么建的」别再翻 services/sessions.ts——去 agent/service.tscreateSession
会话的并发安全靠 version 乐观锁 + expectedVersion 靠数据库 CAS(WHERE status IN (...) + 影响行数判定),事务内完成状态迁移
状态机的真相在 drizzle-repository.tsbeginTurn / transitionSession / terminateRun 三处。
task 的工作目录字段叫 worktree_path,留给将来的 worktree 供给填 working_dir,task 创建时就绑定一个绝对路径,会话直接复用作 cwd
这是字段改名 + 语义提前,不是数据迁移;worktree 隔离仍是后续工作。
「关闭会话」= 删除(soft-delete + 返回 deletedAt) 「关闭」= 状态翻 closed,可被下一轮唤醒;删除路径(deleted_at)预留但本 PR 不写
CLI session 是一组看板 CRUD(含 delete、label 子命令) 是一组 agent 动词:create / start-turn / tail / close(delete 和 label 子命令都删了)
迁移目录里有 3 个迁移文件记录 schema 演进史 压缩成单个 0000_init 基线(项目未发布,无历史需保留);这是机械操作,不是数据丢失

10新词表

会话与运行
agent_sessions统一的会话表,绑定 task + provider + cwd;本 PR 后唯一的会话存储
turn(一轮)一次「用户输入 → agent 处理 → 完成」的交互;开一轮叫 startTurn
run一轮的执行记录行,有唯一 runId;会话同时只能有一个 Running run
closed-resumeclosed 会话再发一轮,使其自动翻回 active——「关掉的对话重新打开」
机制
CAS(compare-and-swap)更新时在 WHERE 里限定当前状态,靠影响行数判断是否抢到;替代 version 乐观锁
原子拆除(terminateRun)把一轮的收尾(翻 run、取消挂起请求、会话回 idle)门控在一次 CAS 上,整段同生共死
workingDir 契约task 绑定的绝对路径,会话复用作 cwd;wire + daemon 两道关拒相对路径
RunnerManager管 provider 子进程生命周期:getOrCreate 造、dispose
ProviderModuleprovider「自报家门」的描述符:factory + builtinSeed + models + sessionConfig
builtinSeedprovider 首次启动时往库里塞的种子行(如内置 claude,executablePath: 'claude'
测试
real-binary e2e驱动真实 claude/codex CLI 的端到端测试,默认 skip,需 PATH + 登录态
narration(叙事层)e2e 里 opt-in 的 NDJSON 步骤轨迹;设了 EYRIE_E2E_NARRATE 才写,专供外部「走读 HTML」生成器
forks poolvitest 的子进程池;默认 worker-thread 池会让 claude 子进程 ENOENT 崩,e2e 必须用 forks

11测试与风险地图

这个 PR 测试比实现还重,钉住了大量生命周期不变式;但「真机能跑」这件事靠的是默认不进 CI 的 opt-in e2e。以下纯事实陈述,不评判。

有兜底的(测试钉住了行为)

薄冰(重要逻辑无常规 CI 兜底 / 已知遗留)

给 reviewer 的定性,不是合并阻塞:上面这些「薄冰」都是作者在 PR 描述里明确列为 out-of-scope 的已知缺口,不是回归。PR 本身 check / typecheck / lint / build 全绿、全量 1105 passed。要拍板的是一个判断:「真机能跑」这件核心能力靠 opt-in、默认不进 CI 的 e2e 撑着,且 Claude 审批这一支真机从未端到端通过——能否接受,取决于你对当前阶段(Phase 1 最小回路)的覆盖预期。

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

13覆盖声明

本次为全量精读,无抽样。流程:先按目录称重定位设计重心,再以 4 个并行子 agent 分头读 4 个子系统(agent 域 / API+DB schema / CLI / 测试)建立地图;报告中出现的每一段代码均由主 agent 亲自 git show pr-66:<path> 读过原文后手工裁剪,未引用子 agent 的二手转述。

亲读的核心文件:agent/service.tsagent/drizzle-repository.tsschemas.tsservices/repos.tsservices/sessions.ts(before+after)、db/src/index.tserrors/{codes,messages,status}.tstrpc.tstrpc/services.tsservices.tsuse-cases/tasks.tscli/commands/session.tsproviders/claude/index.tsregistry.ts、三个 e2e/narration 测试文件。

未逐行读的:2442 行的 0001_snapshot.json 等迁移生成物(已判定为机械压缩并给出 journal 证据),以及单测文件的逐个 assertion 措辞(读了结构与覆盖点,未逐行誊抄)。这两类不影响理解结论。