PR #66 minimal agent session loop:把「看板占位 session」换成「真能跑 agent 的统一会话」
figuretu/eyrie · main...feat/agent-session-loop-e2e(base 6fa33a1) · 2026-06-14 · 作者 ponsde · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自 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 的 src 和 tests。
| 设计重心(要细读) | 可放心略过(机械) |
|---|---|
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.ts 删 sessions 表、agent_sessions 加 task_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 命名空间统一驱动。
以前 · 两张表,会话是看板卡片
(挂 project)
现在 · 一张表,会话能真跑 agent
agent.*
(绑 task)
注意一处不直观的接线:CLI 的 session create 仍然敲 client.sessions.create,但 appRouter 里这个 procedure 现在转调 ctx.services.agent.createSession——旧的路由名保住了,背后的服务换成了新的 AgentService。start-turn / close 则直接走 client.agent.*。
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_id 和 deleted_at
会话现在必须挂在一个 task 上(外键 cascade,task 硬删则会话连带删)。project 不再存在会话上——要 project 时通过 task_id join 出来。同时补了软删列 deleted_at 和一条按 task 过滤的索引。
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 只读不写)
})
② 会话状态枚举:从「看板生命周期」换成「运行时状态」
旧表的 statusKey 是 planned / active / closed(看板视角);新表的 status 是运行时机器状态,且没有 version 乐观锁了——并发安全改由数据库 CAS 兜(见 §5.1)。
idle | 会话就绪,等下一轮输入 |
active | 正在跑一轮(有一个 Running 的 run) |
suspended | daemon 重启时把残留的 active 批量挂起 |
closed | 用户关掉了;但可被下一轮重新唤醒(closed-resume) |
error | 崩溃留下的终态 |
③ workingDirSchema:wire 边界的绝对路径正则
新增的共享 schema,createTask 和 createTaskRepo 都用它。只放行「/ 开头的绝对路径」「~ / ~/ home 前缀」「C:\ 盘符」,其余相对路径在这里就被拒成 validation.failed。
// 会话复用作 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」一致。
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,正是「关掉的会话能被下一轮重新唤醒」的实现点。
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()
...
})
}
5.2原子拆除:terminateRun 把 CAS 当总闸
一轮的收尾(正常结束、被打断、启动失败)统一收口到 terminateRun。它的精髓是「整段拆除门控在一次 CAS 上」:只有当这次调用真的把 run 从 Running 翻走,后续的「取消挂起请求 + 把会话翻回 idle」才执行;否则一行不碰。
这道闸防的是一种竞态:interrupt 在 await 期间,run.completed 可能已经从另一条路把 run 终结了,并故意保留了某个 Resolving 的审批行。如果 terminateRun 不看 CAS 结果硬干,就会把会话状态、挂起行全部清掉,踩坏那条路的成果——而且更晚的一轮可能已经接管了会话。
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 前缀放过,留给下游展开)。
// 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 和会话配置字段。
// 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]
providers/<name>/ 导出一个 ProviderModule(factory + builtinSeed + models + sessionConfig);② 把它 push 进 registry.ts 的 PROVIDER_MODULES;③ 其余(种子落库、可用性探测、会话配置校验)由 registry / AgentService 自动接管,无需改业务代码。6旅程 A:开一个会话
从「用户敲 session create」走到「agent_sessions 里多一行 idle 会话」。走通这条,你就知道一个会话凭什么能创建、cwd 从哪来、四种失败各报什么码。
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。
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。它按顺序四道闸,每道闸对应一个明确的错误码,全过了才插入——绝不留下「插了一半」的孤儿会话。
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 必须恰好有一个工作目录
getTaskWorkingDir 从 task_repos 捞这个 task 的所有 working_dir,恰好一个才返回它;零个或多于一个都返回 null(落回上面的 noWorkingDir)。
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),所以「恰好一个」是设计意图,不是巧合。
projectRepoIds.forEach((projectRepoId, index) => {
deps.repos.createTaskRepo(task.id, projectRepoId, index === 0 ? workingDir : undefined) // 只有第 0 个 repo 拿 workingDir
})
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| 建会话报 404 task.notFound | agent/service.ts:createSession 第②闸 / repo.taskExists(task id 打错或被软删) |
| 建会话报 409 noWorkingDir | drizzle-repository.ts:getTaskWorkingDir 的 dirs.length !== 1(task 没绑或绑了多个 working_dir) |
| cwd 绑成了 daemon 自己的目录 | services/repos.ts:assertAbsoluteWorkingDir 是否被绕过;检查传入是不是相对路径 |
| sessionConfig 被拒 | agent/service.ts:validateConfig → session-config.ts 的 validateSessionConfig |
| 会话建出来 project 不对 | project 是 join 出来的,查 task.projectId,不在 agent_sessions 上 |
7旅程 B:跑一轮
从「start-turn」走到「真实 claude 流出 token、run 回到 idle」。这条旅程的设计眼在顺序:先抢占、后造 runner、失败就拆。
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 启动失败,catch 里 terminateRun 把这一轮拆干净,会话回 idle。
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)。
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 的先后。
runnerManager.getbeginTurn 记一轮setCurrentRun 再驱动(先用后验)beginTurn 做 CAS 抢占getOrCreate 造 runnerterminateRun 拆轮回 idle排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| start-turn 报 409 conflict | drizzle-repository.ts:beginTurn 的 startable 判定或「已有 Running run」分支 |
| turn 失败后会话卡在 active | service.ts:startTurn 的 catch 是否走到 terminateRun('startTurnFailed') |
| 怀疑有孤儿子进程 | service.ts:确认 CAS 在 getOrCreate 之前;RunnerManager.getOrCreate 的 spawn 时机 |
| tail 收不到 run.completed | commands/session.ts:isRunCompletedEvent 的信封拆解(data.event.type);订阅是否带对 sessionId |
| 回复文本是空的但 run 显示 completed | 看 message.completed/message.delta 事件流;e2e 专门断言文本非空正是防这个 |
8旅程 C:关掉再开
从「close」走到「会话变 closed」,再从「对 closed 会话又 start-turn」走到「它自动复活」。这条旅程证明「关闭」不是终点。
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 没命中」也不会留下活子进程。
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」仍然干净返回。
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 待在 beginTurn 的 startable 集合里的直接后果。对 closed 会话发新一轮,CAS 命中,会话从 closed 翻回 active,照常跑。e2e 把这条路完整走了一遍。
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 conflict | service.ts:closeSession 的 transitionSession CAS(会话当时是 active/error,不在允许集合) |
| 关了但子进程还在 | service.ts:runnerManager.dispose 是否在 CAS 之前执行;RunnerManager.dispose |
| 对陌生会话停止却返回成功 | service.ts:interruptCurrentRun 开头的 getSession 是否还在 |
| closed 会话发消息却起不来 | drizzle-repository.ts:beginTurn 的 startable 是否仍含 SessionStatus.Closed |
| error 状态的会话关不掉 | 已知限制:error 不在 close 的允许集合里(见 §11 薄冰 / PR 自述待办) |
9心智模型补丁
读完这个 PR,对项目的几处既有认知要改:
sessions 表)
会话挂在 task 上,是 agent 执行上下文(agent_sessions 表);project 由 task 反推,标签/版本都没了
SessionService.list,它 join tasks 后用 tasks.projectId 过滤,而不是会话自带的列。SessionService 是会话的主业务服务(create/update/delete + 标签关系)
SessionService 只剩只读 + 改 title;真正的 create/生命周期搬到了 AgentService
services/sessions.ts——去 agent/service.ts 的 createSession。version 乐观锁 + expectedVersion
靠数据库 CAS(WHERE status IN (...) + 影响行数判定),事务内完成状态迁移
drizzle-repository.ts 的 beginTurn / transitionSession / terminateRun 三处。worktree_path,留给将来的 worktree 供给填
叫 working_dir,task 创建时就绑定一个绝对路径,会话直接复用作 cwd
worktree 隔离仍是后续工作。closed,可被下一轮唤醒;删除路径(deleted_at)预留但本 PR 不写
session 是一组看板 CRUD(含 delete、label 子命令)
是一组 agent 动词:create / start-turn / tail / close(delete 和 label 子命令都删了)
0000_init 基线(项目未发布,无历史需保留);这是机械操作,不是数据丢失
10新词表
| 会话与运行 | |
|---|---|
agent_sessions | 统一的会话表,绑定 task + provider + cwd;本 PR 后唯一的会话存储 |
| turn(一轮) | 一次「用户输入 → agent 处理 → 完成」的交互;开一轮叫 startTurn |
| run | 一轮的执行记录行,有唯一 runId;会话同时只能有一个 Running run |
| closed-resume | 对 closed 会话再发一轮,使其自动翻回 active——「关掉的对话重新打开」 |
| 机制 | |
| CAS(compare-and-swap) | 更新时在 WHERE 里限定当前状态,靠影响行数判断是否抢到;替代 version 乐观锁 |
| 原子拆除(terminateRun) | 把一轮的收尾(翻 run、取消挂起请求、会话回 idle)门控在一次 CAS 上,整段同生共死 |
| workingDir 契约 | task 绑定的绝对路径,会话复用作 cwd;wire + daemon 两道关拒相对路径 |
| RunnerManager | 管 provider 子进程生命周期:getOrCreate 造、dispose 销 |
| ProviderModule | provider「自报家门」的描述符:factory + builtinSeed + models + sessionConfig |
| builtinSeed | provider 首次启动时往库里塞的种子行(如内置 claude,executablePath: 'claude') |
| 测试 | |
| real-binary e2e | 驱动真实 claude/codex CLI 的端到端测试,默认 skip,需 PATH + 登录态 |
| narration(叙事层) | e2e 里 opt-in 的 NDJSON 步骤轨迹;设了 EYRIE_E2E_NARRATE 才写,专供外部「走读 HTML」生成器 |
| forks pool | vitest 的子进程池;默认 worker-thread 池会让 claude 子进程 ENOENT 崩,e2e 必须用 forks |
11测试与风险地图
这个 PR 测试比实现还重,钉住了大量生命周期不变式;但「真机能跑」这件事靠的是默认不进 CI 的 opt-in e2e。以下纯事实陈述,不评判。
有兜底的(测试钉住了行为)
- 四层 fail-fast(
agent-service-methods.test.ts):createSession 的 provider 检查、task.notFound、taskExists 软删、noWorkingDir 各有用例。 - startTurn CAS 不泄漏:CAS 在
getOrCreate之前失败时,不留 runner 句柄——单测专门验证 CAS-miss 不造 runner。 - closeSession 顺序:interrupt → dispose → CAS;即便 CAS 落空 runner 也已拆。
- closed-resume:
agent-repository.test.ts验证beginTurn对 closed 会话发出 closed→active 的 CAS;session-loop-e2e真机走完整条路。 - interrupt 陌生会话报 404、input.request 敏感字段脱敏(runner 收原文、库/广播收
[REDACTED])均有用例。 - workingDir 守卫:
workflow.test.ts在非 Windows 平台验证「C:\Windows\Temp这类外平台绝对路径被拒、本机绝对路径放行」。 - 真机文本/文件+bash/图像/codex 审批:两个 e2e 套件用「磁盘上真出现
output.txt」「事件流真有tool.started」「回复含 'red'」做断言,模型无法伪造。
薄冰(重要逻辑无常规 CI 兜底 / 已知遗留)
- 🔴Claude 审批 round-trip 端到端未验证:真机审批用例是
describe.skip。原因写在 TODO 里——liveclaude 2.1.173默认模式根本不发can_use_tool控制请求,gated Write 工具会永久阻塞。翻译层本身有单测(mockcan_use_tool → approval.requested → control_response);没被验证的是「这个 claude 版本是否真在 runner 层发那个请求」。下一个 claude 版本若改了行为,集成测试抓不到。 - 🔴所有 real-binary e2e 默认不进 CI:受
EYRIE_CLAUDE_E2E/EYRIE_CODEX_E2E门控,常规1105 passed那次跑里它们全 skip。createSession / closed-resume / CAS 顺序的「真机正确性」只在有人手动设环境变量时才被验证。 - 🟠real-binary 依赖外部二进制版本:e2e 假设
claude/codex在 PATH 且已登录;CLI 版本漂移(工具 API、图像支持、审批协议变动)可能让 e2e 行为变化,无版本固定机制。 - 🟠Codex 审批只在 e2e 覆盖:grant→执行 全路径靠真机 codex 验证;service/repository 单测层没有专门钉 codex 审批字段映射的用例。
- 🟡
closeSession不接受error态:崩溃的会话目前关不掉(PR 自述待办,待重访)。 - 🟡
getTaskWorkingDir对「0 个」和「≥2 个」repo 都返回同一个task.noWorkingDir;多 repo 落地后才给多 repo 场景一个独立码(PR 自述)。 - ⚪
startTurn的输入目前是临时文本 shim(parts 拼成 text、图片占位),等 event/upload DTO 落地后替换(PR 自述)。 - ⚪narration 的 NDJSON 无 schema 校验;外部走读生成器需手工维护解析器。
check / typecheck / lint / build 全绿、全量 1105 passed。要拍板的是一个判断:「真机能跑」这件核心能力靠 opt-in、默认不进 CI 的 e2e 撑着,且 Claude 审批这一支真机从未端到端通过——能否接受,取决于你对当前阶段(Phase 1 最小回路)的覆盖预期。12验收提示(别被这些吓到)
- 净 −1457 行不是功能缩水:删的大头是被替换的占位 session 服务/CLI + 迁移压缩(单
0001_snapshot.json就删了 2442 行)。新功能的代码是净增的。 - 迁移从 3 个变 1 个不是丢数据:项目还没发布过 schema,
0000_init把三个迁移折叠成一份基线是合法的机械重组(journal 也对应从 3 条变 1 条)。task 的priority列没消失,被并进了基线。 worktree_path → working_dir是改名+语义提前,不是 git rename 误判,也不是数据迁移脚本。- Claude 审批用例
describe.skip是有意为之,不是漏写或挂掉的测试——它在等 claude 真的发can_use_tool(TODO 里写明)。旁边还有个「narration-only」占位,专门在抓取叙事轨迹时记一条诚实的 skip 步骤。 - e2e 默认全 skip 是正常的:没设
EYRIE_CLAUDE_E2E/EYRIE_CODEX_E2E时整套跳过,常规 CI 不需要真实二进制。 - CLI 还在敲
client.sessions.create但落点已是 AgentService——路由名保留是兼容考虑,不是接错了服务。 - e2e 直接调
AgentService(不经 tRPC/CLI):session-loop-e2e在进程内 boot 真实 daemon 栈(迁移 + provider 注册表 + RunnerManager + AgentService)打真 claude,是服务层端到端,不是网络层端到端——这是刻意的范围选择。
13覆盖声明
本次为全量精读,无抽样。流程:先按目录称重定位设计重心,再以 4 个并行子 agent 分头读 4 个子系统(agent 域 / API+DB schema / CLI / 测试)建立地图;报告中出现的每一段代码均由主 agent 亲自 git show pr-66:<path> 读过原文后手工裁剪,未引用子 agent 的二手转述。
亲读的核心文件:agent/service.ts、agent/drizzle-repository.ts、schemas.ts、services/repos.ts、services/sessions.ts(before+after)、db/src/index.ts、errors/{codes,messages,status}.ts、trpc.ts、trpc/services.ts、services.ts、use-cases/tasks.ts、cli/commands/session.ts、providers/claude/index.ts、registry.ts、三个 e2e/narration 测试文件。
未逐行读的:2442 行的 0001_snapshot.json 等迁移生成物(已判定为机械压缩并给出 journal 证据),以及单测文件的逐个 assertion 措辞(读了结构与覆盖点,未逐行誊抄)。这两类不影响理解结论。