feat/session-reconfigure:为运行中的 AI 会话添加动态配置修改能力
eyrie · main...feat/session-reconfigure · 2025 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
这个 PR 让用户可以在不终止会话的情况下修改 AI 会话的 model、permissionMode、effort 三个运行参数。提交一次修改后,守护进程先写数据库、再可选地把变更「热推」到活跃进程;热推失败时配置已落库,下次启动自然生效,不会丢。
三种 provider 的热推能力不同:Claude CLI 通过 stdin 发 control_request 即时切换;ACP 通过 session/set_config_option RPC 热应用;Codex 没有热推协议,全部推迟到下次 turn。
附带两个独立收益:① ACP 和 Codex 新增 listModels(model picker 数据来源);② 新增仅 dev 使用的 DB 迁移失败恢复流(DaemonRecoveryDialog),不影响生产路径。
2变更地图(称重)
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
agent/service.ts |
reconfigure 主流程、Promise 锁、pre-start stop race | – |
agent/types.ts |
三个新 reconfigure 类型;AgentProvider / AgentRunner 接口扩展 | – |
providers/claude/ |
runner.ts(hot apply + control_request/response 配对);reconfigure.ts(参数校验);control-protocol.ts(去 hooks) | process.ts 的 flag 重命名(机械) |
agent/acp/ |
discovery.ts(纯新增,model 发现);runner.ts(startup config 重放 + RPC reconfigure);protocol.ts(新增 ACP 消息类型) | json-rpc-connection.ts 的 closedForWrites(防御性小改) |
providers/codex/ |
index.ts(permissionMode 映射 + listModels);reconfigure.ts(参数校验) | – |
db/dev-recovery.ts |
全部(dev-only 独立功能,可单独评估) | – |
daemon/tests/ |
agent-service-methods(813 行新增);claude-runner-reconfigure;acp-runner;codex-runner;acp-provider-registry | 各 fake 对象补 stub(机械适配新接口,约 200 行) |
3架构一图流
reconfigure 是一条新增的控制平面通道,与 startTurn 的数据平面并行,但二者被 Promise 锁序列化在同一 session 内互斥执行。
以前 · 无 reconfigure 通道
现在 · 新增 reconfigure 控制通道
4数据形状先行
4.1核心类型(agent/types.ts)
三个新类型构成了 reconfigure 的语言。ReconfigureParams 是在 provider 边界传递的原始 map,不做解析;ReconfigParamsCheck 是持久化前的 provider 校验报告;ReconfigureResult 是执行后的四桶结果。
// 通用 key-value 包,provider 各自解析,service 层不解释
export type ReconfigureParams = Record<string, unknown>
// provider 对 params 的持久化前校验结果
export type ReconfigParamsCheck = {
accepted: ReconfigureParams // 通过校验,可以写库并转发给 runner
unsupported: string[] // provider 不认识的 key,不写库
invalid: string[] // provider 认识但值不合法,直接返 400
}
// 执行结果四桶——每个提交的 key 必须落入恰好一个桶
export type ReconfigureResult = {
applied: string[] // 已热应用到活跃进程(Claude/ACP 有 live RPC)
unsupported: string[] // provider 不支持,未写库
deferred: string[] // 已写库,下次 turn/spawn 生效(Codex 全部到这里)
failed: string[] // provider 拒绝或 RPC 失败(库已写,下次 spawn 会重试)
}
AgentProvider 接口新增两个必选方法,AgentRunner 接口新增一个必选方法:
export interface AgentProvider {
// ... 已有字段 ...
readonly usesProviderDefaultModel?: boolean // Codex = true,创建 session 时不强插默认 model
listModels?(): Promise<AgentModelOption[]> // 可选;ACP 和 Codex 实现了,Claude 没有
checkReconfigParams(params: ReconfigureParams): MaybePromise<ReconfigParamsCheck>
validateRunnerConfig(config: RunnerConfig): MaybePromise<void>
}
export interface AgentRunner {
// ... 已有方法 ...
reconfigure(params: ReconfigureParams): Promise<ReconfigureResult>
}
此外新增一个共享工具函数,供没有热推能力的 runner 使用,避免各自重复实现:
export function deferReconfigure(params: ReconfigureParams): Promise<ReconfigureResult> {
return Promise.resolve({
applied: [],
unsupported: [],
deferred: Object.keys(params), // 所有 key 全打入 deferred
failed: [],
})
}
4.2API schema(packages/api/src/schemas.ts)
tRPC 层的输入格式是单次一个 key-value 对。value 字段的处理有个细节:用 Object.hasOwn 区分"没传 value"(客户端错误)和"传了 undefined",因为 Zod 的 .optional() 对两种情况无法区分。只有 model 字段在 API 层做 trim/长度校验,其他 key 的 value 透传为 opaque unknown,交给 provider 的 checkReconfigParams 处理。
export const reconfigureSessionSchema = z
.object({
sessionId: idSchema,
key: z.string().trim().min(1).max(200),
value: z.unknown().optional(),
})
.transform((input, ctx) => {
if (!Object.hasOwn(input, 'value')) { // value 缺失 = 客户端错误
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'value is required' })
return z.NEVER
}
if (input.key !== 'model') return { ...input, value: input.value }
const parsed = sessionModelSchema.safeParse(input.value) // trim + 长度校验
if (!parsed.success) { ctx.addIssue(...); return z.NEVER }
return { ...input, value: parsed.data }
})
5底座:service 层机制
reconfigure 的核心机制在 AgentService 里,有三处需要预先理解,旅程章节才能跟上。
5.1Promise 链锁(withSessionRunnerLock)
以前 startTurn、resumeSession、reconfigure 三条路径对同一 session 是并发无序的。现在三条路径都通过一个 per-session 的 Promise 链锁串行化。锁的实现不依赖 Mutex 库——用 Map<sessionId, Promise<void>> 模拟队列,每个入口把自己排在当前末尾 promise 之后。
private async withSessionRunnerLock<T>(sessionId: string, run: () => Promise<T>): Promise<T> {
const previous = this.sessionRunnerQueues.get(sessionId) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => { release = resolve })
const queued = previous.then(
() => current,
() => current, // 前一个失败时也继续,不卡死队列
)
this.sessionRunnerQueues.set(sessionId, queued)
await previous.catch(() => undefined) // 等上一个完成或失败,忽略其错误
try {
return await run()
} finally {
release()
if (this.sessionRunnerQueues.get(sessionId) === queued) {
this.sessionRunnerQueues.delete(sessionId) // 自己是最后一个才清 map,防止 leak
}
}
}
5.2DB-first 持久化与 reconcileCommittedReconfigure
reconfigure 流程的核心语义:库先于进程写,热推失败不回滚。persistReconfigure 在调用 runner 之前执行。如果 runner 的 hot apply 部分失败(failed 或 unsupported),reconcileCommittedReconfigure 把这些 key 折叠进 deferred——因为库已经写了,下次 spawn 时这些 key 自然生效。同时把 runner 标记为 stale,让下次 getOrCreate 重建。
function reconcileCommittedReconfigure(
params: ReconfigureParams,
liveResult: ReconfigureResult,
): { result: ReconfigureResult; markStale: boolean } {
const applied = new Set(liveResult.applied.filter((key) => Object.hasOwn(params, key)))
const deferred = Object.keys(params).filter((key) => !applied.has(key))
// liveResult.failed 和 liveResult.unsupported 都被折叠进 deferred(库已写),调用方永远不会看到 failed
return {
result: { applied: [...applied], unsupported: [], deferred, failed: [] },
markStale: liveResult.failed.length > 0 || liveResult.unsupported.length > 0,
}
}
patchSessionConfig(DrizzleAgentRepository)的持久化逻辑:model 是独立列,其他 key 进 sessionConfigJson 做浅合并,整个读-改-写包在 SQLite 事务里。
for (const key of Object.keys(params)) {
const value = params[key]
if (key === 'model' && typeof value === 'string') {
model = value // model 是 DB 独立列,不能进 sessionConfigJson
continue
}
sessionConfigJson[key] = value
}
5.3pre-start stop 竞态修复(preStartStoppedRuns)
场景:startTurn 正在创建 runner(耗时),此时 interruptCurrentRun 或 closeSession 到达。runner 还不存在,没法调 runner.interrupt()。以前这个窗口期的 stop 会被静默忽略,runner 建好后 turn 照常执行。现在的修复是:检测到 runner 正在创建中(isCreatingHandle 检查 Promise duck-typing),把 runId 记录进 preStartStoppedRuns;等 startTurn 拿到锁后,assertRunCanReachProvider 检查这个标记,发现被抢先 stop 则抛 resource.conflict,跳过 runner.startTurn() 调用。
private async assertRunCanReachProvider(sessionId: string, runId: string): Promise<void> {
const currentRun = await this.repo.getRun(runId)
if (
this.preStartStoppedRuns.get(sessionId)?.has(runId) || // 被 interrupt 抢先标记
!currentRun ||
currentRun.status !== RunStatus.Running
) {
throw new AppError({ code: EyrieErrorCode.resource.conflict })
}
}
private consumePreStartStop(sessionId: string, runId: string): boolean {
const stops = this.preStartStoppedRuns.get(sessionId)
if (!stops?.delete(runId)) return false
if (stops.size === 0) this.preStartStoppedRuns.delete(sessionId)
return true // true 表示 startTurn 的 catch 不需要再调 terminateAndBroadcast(interrupt 已经做了)
}
6旅程 A:idle 会话 reconfigure(无 live runner)
最简单的路径:session 处于 idle 状态(没有 runner 在跑),提交一次 reconfigure。参数写库后全部归入 deferred,下次 startTurn 建 runner 时,RunnerManager.getOrCreate 从最新 session row 读出新配置,新进程天然携带它。
trpc/services.ts→ AgentService.reconfigure
agent/service.ts→ provider.checkReconfigParams
providers/claude/reconfigure.ts→ repo.patchSessionConfig
agent/drizzle-repository.ts→ 返回 { deferred: [...] }
A.1tRPC wire 层展开单字段输入
tRPC 路由收到的是单次一个 { key, value }。wire 层把它展开为 { [key]: value } 再传给 service,然后用 assertReconfigureSucceeded 把四桶结果映射为 HTTP 状态码:applied 或 deferred 返回更新后的 SessionDto;unsupported 返回 400;failed 返回 409。
reconfigure: async (sessionId, input) => {
const { session, result } = await deps.agentSessions.reconfigure(sessionId, {
[input.key]: input.value, // 单字段展开为 ReconfigureParams
})
assertReconfigureSucceeded(input.key, result) // deferred 和 applied 都视为成功
return toSessionDto(session, deps.tasks.projectIdOfTask(session.taskId))
}
A.2reconfigureUnlocked:三关校验
进入锁后执行 reconfigureUnlocked,经过三道关:① provider 校验参数(checkReconfigParams);② 用假想 post-patch config 跑 provider 完整启动校验(validateRunnerConfig);③ 写库。任何一关失败,后面步骤不执行。
const paramsCheck = await runtimeProvider.checkReconfigParams(normalizedParams)
if (paramsCheck.invalid.length > 0) throw reconfigureValidationError(paramsCheck.invalid)
// invalid 阻断整个请求;unsupported 不写库但不阻断
if (Object.keys(paramsCheck.accepted).length === 0) {
return { session, result: { applied: [], unsupported: paramsCheck.unsupported, deferred: [], failed: [] } }
}
await validateReconfigureRunnerConfig(runtimeProvider, session, paramsCheck.accepted, this.idgen)
// buildReconfigureRunnerConfig 合成假想的 post-patch RunnerConfig 供 provider 做完整校验
const updated = await this.persistReconfigure(session, paramsCheck.accepted)
if (!handleOrCreating) {
// idle 路径:没有 live runner,全 deferred,结束
return { session: updated, result: mergeUnsupported(await deferReconfigure(paramsCheck.accepted), paramsCheck.unsupported) }
}
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
| reconfigure 返回 400 validation.failed | agent/service.ts:reconfigureUnlocked 里的 paramsCheck.invalid 检查;providers/claude/reconfigure.ts:checkClaudeReconfigParams |
| reconfigure 返回 unsupported,但期望 accepted | 对应 provider 的 checkReconfigParams 实现(claude/codex/acp 各一个) |
| DB 里没有新 model,但 reconfigure 返回成功 | agent/drizzle-repository.ts:patchSessionConfig;检查 model 列与 sessionConfigJson 的分流逻辑 |
7旅程 B:active 会话 live reconfigure(有 live runner)
会话有活跃 runner 时,持久化之后还会调 runner.reconfigure(params) 尝试热推。三种 provider 的热推机制各不相同。
service.ts→ patchSessionConfig
drizzle-repository.ts→ applyLiveReconfigure
service.ts→ Claude / ACP / Codex runner→ reconcileCommittedReconfigure
service.ts
B.1Claude:stdin control_request + stdout control_response
Claude CLI 原先只有 Claude→Eyrie 方向的控制协议(permission 请求等)。这个 PR 新增了 Eyrie→Claude 方向:通过 stdin 写一条 JSON 控制请求,等 Claude 从 stdout 回 control_response,超时 5 秒视为失败。
前提变更:initialize 控制请求不再携带 hooks 字段(旧版把 permissionMode 编码进 hooks 的正则 matcher,热切换要求重建 hooks)。现在 hooks 字段置空,permissionMode 改为通过独立的 set_permission_mode control_request 管理。
set_permission_mode control_request 独立管理pendingControlResponses map,配对 request_id 与 waiter Deferred三种参数各有不同的 wire 格式:
const CLAUDE_RECONFIGURE_REQUEST_RULES = {
model: { subtype: 'set_model', valueField: 'model' },
permissionMode: { subtype: 'set_permission_mode', valueField: 'mode' },
effort: { subtype: 'apply_flag_settings', settingsField: 'effortLevel' },
// effort 包进 settings 对象,其他两个直接放顶层字段
} as const
请求/响应配对用一个 5 秒的 Promise.race 实现:
const waiter = createDeferred<void>()
this.pendingControlResponses.set(requestId, waiter)
await process.writeLine(JSON.stringify(request))
return await Promise.race([
waiter.promise.then(() => true, () => false),
delay(RECONFIGURE_TIMEOUT_MS).then(() => false), // 5 秒超时 → 报 failed
])
B.2ACP:session/set_config_option RPC
ACP 通过 session config option 机制热应用。runner 维护一个 configOptions 内存快照(从 session/new 响应和运行时 config_option_update 通知更新),reconfigure 时在快照里找到对应分类的 select option,确认目标值合法后发 RPC,响应里携带更新后的完整快照用来刷新缓存。
ACP 只支持 model(映射到 category 'model')和 effort(映射到 category 'thought_level');permissionMode 被列为 unsupported——ACP 自管权限,Eyrie 不干预。
ACP runner 还有一个 startup config 重放机制:每次 session/new 或 session/resume 返回后,立即执行 applyStartupConfig,把已持久化的 model/effort 通过 session/set_config_option 推送给 provider,使 provider session 与 DB 状态对齐。重放失败则抛异常终止 session 建立。
B.3Codex:全 deferred(内存写,下次 turn 生效)
Codex app-server 没有热切换协议。reconfigure 只把合法参数写入 this.providerConfig 内存对象,全部报告为 deferred。下一次 startTurn 时,turnSettings() 读取最新的 providerConfig 组装 turn/start 参数体,新的 model/effort/permissionMode 自然生效。
permissionMode 在每次 turn 时映射为 Codex 原生的 approvalPolicy + sandboxPolicy 组合:
case 'readOnly':
return { approvalPolicy: 'on-request', sandboxPolicy: { type: 'readOnly', networkAccess: false } }
case 'workspaceWrite':
return { approvalPolicy: 'on-request', sandboxPolicy: { type: 'workspaceWrite', writableRoots: [cwd], ... } }
case 'auto':
return { approvalPolicy: 'on-failure', sandboxPolicy: workspaceWriteSandboxPolicy(cwd) }
case 'fullAccess':
return { approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' } }
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| Claude reconfigure 返回 failed(热应用失败) | providers/claude/runner.ts:pendingControlResponses map;handleControlResponseLine;检查 5000ms 超时或 control_response error subtype |
| Claude permissionMode 改了但 approval routing 没变 | providers/claude/runner.ts:确认 set_permission_mode control_request 是否发出;control-protocol.ts:initialize 请求确认 hooks 字段为空 |
| ACP reconfigure 返回 unsupported(model/effort 也不支持) | agent/acp/runner.ts:configOptions 快照是否为空;findSelectConfigOption 能否找到对应 category |
| ACP session 启动后 model 没有生效 | agent/acp/runner.ts:applyStartupConfig;确认 session/new 或 session/resume 响应后是否执行了 set_config_option |
| Codex reconfigure 后下次 turn model 没变 | providers/codex/index.ts:turnSettings() 和 providerConfig 读取;检查 reconfigure 是否成功写入内存对象 |
8旅程 C:startTurn 与 reconfigure 并发
同一 session 同时到来 startTurn 和 reconfigure 时,Promise 锁保证串行。更微妙的竞态是:runner 正在创建中(creating 阶段),interrupt 抢先到达,runner 还不存在没法 interrupt。
isCreatingHandle 检测到 creating 是 Promise,记录 markPreStartStop(runId)assertRunCanReachProvider 发现标记,抛 resource.conflictconsumePreStartStop → 返回 true → 跳过 terminateAndBroadcastreconfigure 在 startTurn 持有锁期间只是排队等待,不会出现数据撕裂——它进锁后读到的是 startTurn 已落库的最新 session row(startTurn 锁内调 getRunnableSession 二次读取)。
startTurn 调 getRunnableSession(sessionId) 重新读一次 session,而非用进锁前读的快照。这样如果 reconfigure 在 startTurn 之前写入了新 model,新 runner 会从最新 session row 得到它。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| interrupt 了但 turn 仍然执行 | agent/service.ts:isCreatingHandle;markPreStartStop;assertRunCanReachProvider 的 preStartStoppedRuns 检查 |
| reconfigure 长时间等待,疑似锁不释放 | agent/service.ts:withSessionRunnerLock 的 finally 块;检查 sessionRunnerQueues map 是否有 leak |
9旅程 D:DB 迁移失败恢复(dev-only)
开发中频繁改 Drizzle schema 而不写迁移文件,导致已有的 .db 文件与新 schema 不兼容,migrate() 抛错,daemon 进程死亡。这套恢复流程让开发者在不重启应用、不手动删数据库的情况下原地恢复。生产路径完全不受影响(正常 openDb 成功时跳过整个 catch 块)。
db/dev-recovery.ts→ runRecoveryServer
db/dev-recovery.ts→ DaemonRecoveryDialog
desktop/DaemonRecoveryDialog.tsx→ POST /recovery/resolve→ seedSampleProject
daemon/src/index.ts
D.1daemon 侧:接管端口,等待 renderer 决策
openDbWithRecovery 包裹 openDb,迁移失败时调 runRecoveryServer,用 Hono 在 daemon 的同一端口绑定一个最小 HTTP 服务(没有 /health,所以 renderer 的 health poll 一直失败,触发 probe)。renderer 探测到 /recovery/status 返回 { state: 'migration_failed', dbPath, eyrieDir } 才弹对话框。
export async function openDbWithRecovery(options: RecoveryOptions): Promise<RecoveredDb> {
try {
return { dbHandle: openDb({ path: options.path }), seedRequested: false }
} catch (err) {
logger.error({ err, dbPath: options.path }, 'database migration failed; entering recovery mode')
const action = await runRecoveryServer(options) // 阻塞直到 renderer 做出选择
if (action === 'cancel') throw err // cancel:重新 throw,daemon 退出
deleteDatabaseFiles(options.path) // 删三个 WAL 文件
return { dbHandle: openDb({ path: options.path }), seedRequested: action === 'reset_and_seed' }
}
}
D.2renderer 侧:DaemonRecoveryDialog
Dialog 只有在两个条件同时满足时才显示:health poll 处于 error 状态,且 /recovery/status 返回 200 + state: 'migration_failed'。任何一个不满足(daemon 未响应、正常 unreachable)都回落到 DaemonUnreachableNotice。Dialog 不可关闭(ESC 和点击外侧均被 preventDefault)。
用户三选一:Cancel(让 daemon 退出)/ Reset database(删库重迁移)/ Reset with sample data(删库 + 种入样本项目)。POST /recovery/resolve 发送后立刻 refetch health 和 probe,无需等下一个 interval。
D.3seedSampleProject:复用 use-case 层写样本数据
用户选 reset+seed 时,openDbWithRecovery 返回 seedRequested: true,main() 在服务层全部组装完成后调用 seedSampleProject。seed 函数通过 useCases.projects.create / daemonServices.tasks.create 写入,不直接操作 DB。seed 失败只 log 不崩溃。
排查路标 · 旅程 D
| 症状 | 从哪下手 |
|---|---|
| Dialog 没弹出,daemon 已经挂了 | db/dev-recovery.ts:runRecoveryServer;确认 Hono 是否成功绑定端口;desktop 的 /recovery/status probe 频率(3s interval) |
| reset 后 seed 没有生效 | daemon/src/index.ts:seedSampleProject 调用;检查 log 里是否有 seed 失败警告 |
10心智模型补丁
model 列和 sessionConfigJson 是最新配置的真相源
DB-first 语义:库写成功就算 committed,即使 hot apply 失败也不回滚
getOrCreate 一直返回同一个 runner,直到 session 关闭
必须知道:markStale 后的下次 getOrCreate 会 dispose 旧 runner 并重建,携带最新持久化配置
hot apply 失败(runner 无法接受某 key)时触发,确保最终一致性
withSessionRunnerLock 串行化,同一 session 下任意时刻只有一个在运行
防止 runner 创建与配置更新的数据竞争
set_permission_mode control_request 独立管理
使 permissionMode 可以在进程启动后热切换,而不依赖启动时固化的 hook 规则
usesProviderDefaultModel = true,createSession 不强插默认 model,让 Codex 自选
Codex 有自己的模型选择逻辑,Eyrie 不应覆盖它
configOptions 内存快照,通过 RPC 写配置;每次新建/恢复 session 都会重放持久化配置
11新词表
| 词 | 白话解释 | 域 |
|---|---|---|
ReconfigureParams | 通用 key-value map,provider 各自解析,service 层不解释内容 | service/types |
ReconfigParamsCheck | provider 在写库前做的静态校验结果,三桶:accepted / unsupported / invalid | service/types |
ReconfigureResult | 执行结果四桶:applied(热生效)/ deferred(待下次 turn)/ unsupported / failed | service/types |
deferReconfigure | 把所有 key 全打入 deferred 桶的工具函数,供没有热推能力的 runner 共用 | types.ts |
withSessionRunnerLock | 基于 Promise 链的 per-session 互斥锁,串行化 reconfigure / startTurn / resumeSession | service.ts |
markStale | 标记该 session 的 runner 需要在下次 getOrCreate 时废弃重建;hot apply 失败时触发 | RunnerManager |
getOrCreating | 返回已建好的 RunnerHandle 或正在创建中的 Promise,不触发新建;与 get 的区别是也返回 creating 阶段 | RunnerManager |
preStartStoppedRuns | 记录「在 runner 创建窗口期内被 interrupt/close 提前终止」的 runId 集合,防止 startTurn 在 runner 建好后还执行 | service.ts |
patchSessionConfig | 浅合并式 DB 写入:model 走独立列,其他 key 合并进 sessionConfigJson;与 updateSession(全列覆盖)不同 | DrizzleAgentRepository |
buildReconfigureRunnerConfig | 合成假想 post-patch RunnerConfig 供 provider 的完整启动校验复用,避免为 reconfigure 单独写校验规则 | service.ts |
discoverAcpModels | ACP model 发现:spawn 一个短命 session,从 session/new 响应里提取 model 列表,然后关闭 | acp/discovery.ts |
startup config 重放 | ACP runner 在 session/new 或 session/resume 后,把持久化的 model/effort 通过 set_config_option 推送给 provider session | acp/runner.ts |
openDbWithRecovery | dev-only,对 openDb 的包装,迁移失败时接管端口等待 renderer 交互(reset/reset+seed/cancel) | db/dev-recovery.ts |
control_request / control_response | Claude CLI 的 Eyrie→Claude 控制协议(新方向);set_model、set_permission_mode、apply_flag_settings 三种子类型 | Claude runner |
12测试与风险地图
有测试兜底的行为
- AgentService.reconfigure 正常流:live runner reported deferred / failed → 折叠为 deferred + markStale
- AgentService.reconfigure 并发安全:reconfigure 等 startTurn 锁、startTurn 二次读 session、preStartStop race
- AgentService.reconfigure 阻断条件:invalid key 不写 DB、validateRunnerConfig 失败不写 DB、DB 失败不调 runner
- Claude runner hot apply:model / permissionMode / effort 三种参数;进程不存在时 deferred;control error → failed;超时 → failed;未知 key → unsupported
- Claude initialize 不再携带 hooks 字段(regression test)
- ACP runner startup config 重放;model/effort RPC;config_option_update 刷新缓存后 reconfigure 使用新值;runner 关闭时 deferred 不写 RPC
- Codex runner listModels;deferred reconfigure;invalid effort → failed;permissionMode 映射为正确的 sandboxPolicy
- ACP provider checkReconfigParams;validateRunnerConfig(discovery probe);listModels(两种数据来源);probe 超时关闭连接
- DrizzleAgentRepository patchSessionConfig 浅合并语义
- RunnerManager markStale + 下次 getOrCreate 重建;dispose 等待 creating 完成
薄冰(重要逻辑无测试)
- 🟠
withSessionRunnerLock的 map 清理路径——并发多条链且中间一条失败时,sessionRunnerQueues的清理行为未被测试 - 🟠
discoverAcpModels/discoverAcpSessionState(discovery.ts 全部函数)无独立单元测试;withDiscoveryRequestDeadline超时分支、deriveAcpModelOptions两条路径均无直接断言 - 🟠
AcpAgentProvider.validateRunnerConfig(调 discoverAcpSessionState 做异步值域校验)无测试 - 🟡Claude runner
reconfigure在进程执行 turn 期间(非 idle)的并发行为未测试 - 🟡Codex
permissionSettings各分支(workspaceWrite / auto)的 sandboxPolicy 具体字段结构未直接断言 - 🟡ACP startup config 重放失败(provider 新版不再支持旧的 effort 值)会导致 session 无法启动,无 graceful degradation,无测试
- ⚪
mergeUnsupported函数(两段 unsupported 合并)无单独测试
configOptions 暴露 thought_level category,applyStartupConfig 会抛异常,session 无法建立。这种「存了合法值但 provider 改了 schema 就挂」的场景目前没有 graceful degradation 路径,合并前需确认是否可接受。
13验收提示
- ACP permissionMode 是有意的 unsupported:ACP provider 自管权限,Eyrie 不提供 permissionMode reconfigure,这不是遗漏。
- Codex effort 枚举比 Claude 多
ultra:两套枚举独立维护,差异是有意的(Codex 支持 ultra,Claude 不支持)。 DaemonRecoveryDialog和dev-recovery.ts是 dev-only 功能:顶部注释明确标注「remove before open-sourcing」。生产路径:openDbWithRecovery的 try 成功直接返回,整个 catch 块不执行。usesProviderDefaultModel = true导致 Codex session 的 model 列可为 null:这是有意行为,Codex 自选默认 model,不是数据损坏。- tRPC wire 层把
deferred和applied都返回成功:调用方无法区分两者,这是有意设计——配置已落库,效果不同只是时间问题。
14覆盖声明
本报告基于对以下文件的全量精读(Read):agent/service.ts、agent/types.ts、agent/runner-manager.ts、providers/claude/reconfigure.ts、providers/claude/runner.ts(前 200 行)、agent/acp/discovery.ts(前 80 行)、db/dev-recovery.ts(前 80 行)。其余文件(providers/codex/index.ts、acp/runner.ts、acp/protocol.ts、drizzle-repository.ts、packages/api/schemas.ts、desktop/DaemonRecoveryDialog.tsx、全部测试文件)通过三个并行 subagent 精读 diff 后二次核对关键结论。全量 diff 每行均在某个 agent 的视野内,无抽样略读。