feat/session-reconfigure:为运行中的 AI 会话添加动态配置修改能力

eyrie · main...feat/session-reconfigure · 2025 · 自包含,读完即弃

51 commits
68 文件
+4981 / −242
56% 是测试代码
混合型:功能 + 修复

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

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变更地图(称重)

daemon/src
≈ 1640 行 · 全是设计
daemon/tests
≈ 2300 行 · 新增测试为主
desktop/src
≈ 153 行 · DaemonRecoveryDialog
packages/api
≈ 171 行 · schema + 接口
子系统设计重心(要细读)可放心略过
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 通道

tRPC
startTurn
AgentService
AgentService
createRunner
Provider Runner
AgentService
无 reconfigure

现在 · 新增 reconfigure 控制通道

tRPC
reconfigure(新增)
AgentService
AgentService
patchSessionConfig(新增)
SQLite DB
AgentService
runner.reconfigure(新增)
Provider Runner
Claude Runner
stdin control_request / stdout control_response
Claude CLI

4数据形状先行

4.1核心类型(agent/types.ts)

三个新类型构成了 reconfigure 的语言。ReconfigureParams 是在 provider 边界传递的原始 map,不做解析;ReconfigParamsCheck 是持久化前的 provider 校验报告;ReconfigureResult 是执行后的四桶结果。

apps/daemon/src/agent/types.ts新增类型
// 通用 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 接口新增一个必选方法:

apps/daemon/src/agent/types.ts接口扩展
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 使用,避免各自重复实现:

apps/daemon/src/agent/types.tsdeferReconfigure
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 处理。

packages/api/src/schemas.tsreconfigureSessionSchema
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)

以前 startTurnresumeSessionreconfigure 三条路径对同一 session 是并发无序的。现在三条路径都通过一个 per-session 的 Promise 链锁串行化。锁的实现不依赖 Mutex 库——用 Map<sessionId, Promise<void>> 模拟队列,每个入口把自己排在当前末尾 promise 之后。

apps/daemon/src/agent/service.tswithSessionRunnerLock
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 部分失败(failedunsupported),reconcileCommittedReconfigure 把这些 key 折叠进 deferred——因为库已经写了,下次 spawn 时这些 key 自然生效。同时把 runner 标记为 stale,让下次 getOrCreate 重建。

apps/daemon/src/agent/service.tsreconcileCommittedReconfigure
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 事务里。

apps/daemon/src/agent/service.tspersistReconfigure 中的 model 路由
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(耗时),此时 interruptCurrentRuncloseSession 到达。runner 还不存在,没法调 runner.interrupt()。以前这个窗口期的 stop 会被静默忽略,runner 建好后 turn 照常执行。现在的修复是:检测到 runner 正在创建中(isCreatingHandle 检查 Promise duck-typing),把 runId 记录进 preStartStoppedRuns;等 startTurn 拿到锁后,assertRunCanReachProvider 检查这个标记,发现被抢先 stop 则抛 resource.conflict,跳过 runner.startTurn() 调用。

apps/daemon/src/agent/service.tsassertRunCanReachProvider + consumePreStartStop
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 读出新配置,新进程天然携带它。

全景 · 涉及 5 个文件
tRPC wire
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 状态码:applieddeferred 返回更新后的 SessionDto;unsupported 返回 400;failed 返回 409。

apps/daemon/src/trpc/services.tsreconfigure wire 层
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);③ 写库。任何一关失败,后面步骤不执行。

apps/daemon/src/agent/service.tsreconfigureUnlocked(核心流程)
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.failedagent/service.tsreconfigureUnlocked 里的 paramsCheck.invalid 检查;providers/claude/reconfigure.tscheckClaudeReconfigParams
reconfigure 返回 unsupported,但期望 accepted对应 provider 的 checkReconfigParams 实现(claude/codex/acp 各一个)
DB 里没有新 model,但 reconfigure 返回成功agent/drizzle-repository.tspatchSessionConfig;检查 model 列与 sessionConfigJson 的分流逻辑

7旅程 B:active 会话 live reconfigure(有 live runner)

会话有活跃 runner 时,持久化之后还会调 runner.reconfigure(params) 尝试热推。三种 provider 的热推机制各不相同。

全景 · 持久化后分三叉
AgentService
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 管理。

以前
initialize 请求携带 hooks matcher(permissionMode 编码进正则规则)
热切换 permissionMode 需要重建进程(hooks 无法更新)
现在
initialize 请求 hooks 字段为空
permissionMode 通过 set_permission_mode control_request 独立管理
runner 维护 pendingControlResponses map,配对 request_id 与 waiter Deferred

三种参数各有不同的 wire 格式:

apps/daemon/src/agent/providers/claude/runner.tsCLAUDE_RECONFIGURE_REQUEST_RULES
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 实现:

apps/daemon/src/agent/providers/claude/runner.tshot apply 超时竞争
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/newsession/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 组合:

apps/daemon/src/agent/providers/codex/index.tspermissionSettings(节选)
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.tspendingControlResponses 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.tsconfigOptions 快照是否为空;findSelectConfigOption 能否找到对应 category
ACP session 启动后 model 没有生效agent/acp/runner.tsapplyStartupConfig;确认 session/new 或 session/resume 响应后是否执行了 set_config_option
Codex reconfigure 后下次 turn model 没变providers/codex/index.tsturnSettings()providerConfig 读取;检查 reconfigure 是否成功写入内存对象

8旅程 C:startTurn 与 reconfigure 并发

同一 session 同时到来 startTurn 和 reconfigure 时,Promise 锁保证串行。更微妙的竞态是:runner 正在创建中(creating 阶段),interrupt 抢先到达,runner 还不存在没法 interrupt。

以前(无锁)
startTurn 调 getOrCreate,开始 spawn
interrupt 到达,无法调 runner.interrupt()(runner 未存在),静默丢弃
runner 建好后 turn 照常执行
现在(有锁 + preStartStop)
startTurn 进锁,调 getOrCreate spawn runner
interrupt 到达,isCreatingHandle 检测到 creating 是 Promise,记录 markPreStartStop(runId)
startTurn 在锁内:assertRunCanReachProvider 发现标记,抛 resource.conflict
catch 块调 consumePreStartStop → 返回 true → 跳过 terminateAndBroadcast

reconfigure 在 startTurn 持有锁期间只是排队等待,不会出现数据撕裂——它进锁后读到的是 startTurn 已落库的最新 session row(startTurn 锁内调 getRunnableSession 二次读取)。

startTurn 锁内重读 session:进锁后 startTurngetRunnableSession(sessionId) 重新读一次 session,而非用进锁前读的快照。这样如果 reconfigure 在 startTurn 之前写入了新 model,新 runner 会从最新 session row 得到它。
排查路标 · 旅程 C
症状从哪下手
interrupt 了但 turn 仍然执行agent/service.tsisCreatingHandlemarkPreStartStopassertRunCanReachProvider 的 preStartStoppedRuns 检查
reconfigure 长时间等待,疑似锁不释放agent/service.tswithSessionRunnerLock 的 finally 块;检查 sessionRunnerQueues map 是否有 leak

9旅程 D:DB 迁移失败恢复(dev-only)

开发中频繁改 Drizzle schema 而不写迁移文件,导致已有的 .db 文件与新 schema 不兼容,migrate() 抛错,daemon 进程死亡。这套恢复流程让开发者在不重启应用、不手动删数据库的情况下原地恢复。生产路径完全不受影响(正常 openDb 成功时跳过整个 catch 块)。

全景 · 涉及 3 个文件
openDbWithRecovery
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 } 才弹对话框。

apps/daemon/src/db/dev-recovery.tsopenDbWithRecovery(核心逻辑)
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: truemain() 在服务层全部组装完成后调用 seedSampleProject。seed 函数通过 useCases.projects.create / daemonServices.tasks.create 写入,不直接操作 DB。seed 失败只 log 不崩溃。

排查路标 · 旅程 D
症状从哪下手
Dialog 没弹出,daemon 已经挂了db/dev-recovery.tsrunRecoveryServer;确认 Hono 是否成功绑定端口;desktop 的 /recovery/status probe 频率(3s interval)
reset 后 seed 没有生效daemon/src/index.tsseedSampleProject 调用;检查 log 里是否有 seed 失败警告

10心智模型补丁

可以假设:provider 配置在会话创建时固定,整个会话生命周期不变 必须知道:model、permissionMode、effort 可以在会话运行时通过 reconfigure 修改;session row 的 model 列和 sessionConfigJson 是最新配置的真相源

DB-first 语义:库写成功就算 committed,即使 hot apply 失败也不回滚

可以假设:getOrCreate 一直返回同一个 runner,直到 session 关闭 必须知道:markStale 后的下次 getOrCreate 会 dispose 旧 runner 并重建,携带最新持久化配置

hot apply 失败(runner 无法接受某 key)时触发,确保最终一致性

可以假设:startTurn、resumeSession、reconfigure 是独立的并发操作 必须知道:三者都通过 withSessionRunnerLock 串行化,同一 session 下任意时刻只有一个在运行

防止 runner 创建与配置更新的数据竞争

可以假设:Claude initialize 请求包含 hooks matcher(决定哪些工具需要 approval) 必须知道:initialize 不再携带 hooks;permissionMode 由后续 set_permission_mode control_request 独立管理

使 permissionMode 可以在进程启动后热切换,而不依赖启动时固化的 hook 规则

可以假设:Codex session 创建时会写入一个默认 model 必须知道:Codex 的 usesProviderDefaultModel = true,createSession 不强插默认 model,让 Codex 自选

Codex 有自己的模型选择逻辑,Eyrie 不应覆盖它

可以假设:ACP provider 只有 model,没有运行时配置状态 必须知道:ACP runner 现在维护 configOptions 内存快照,通过 RPC 写配置;每次新建/恢复 session 都会重放持久化配置

11新词表

白话解释
ReconfigureParams通用 key-value map,provider 各自解析,service 层不解释内容service/types
ReconfigParamsCheckprovider 在写库前做的静态校验结果,三桶:accepted / unsupported / invalidservice/types
ReconfigureResult执行结果四桶:applied(热生效)/ deferred(待下次 turn)/ unsupported / failedservice/types
deferReconfigure把所有 key 全打入 deferred 桶的工具函数,供没有热推能力的 runner 共用types.ts
withSessionRunnerLock基于 Promise 链的 per-session 互斥锁,串行化 reconfigure / startTurn / resumeSessionservice.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
discoverAcpModelsACP model 发现:spawn 一个短命 session,从 session/new 响应里提取 model 列表,然后关闭acp/discovery.ts
startup config 重放ACP runner 在 session/new 或 session/resume 后,把持久化的 model/effort 通过 set_config_option 推送给 provider sessionacp/runner.ts
openDbWithRecoverydev-only,对 openDb 的包装,迁移失败时接管端口等待 renderer 交互(reset/reset+seed/cancel)db/dev-recovery.ts
control_request / control_responseClaude CLI 的 Eyrie→Claude 控制协议(新方向);set_model、set_permission_mode、apply_flag_settings 三种子类型Claude runner

12测试与风险地图

有测试兜底的行为

薄冰(重要逻辑无测试)

ACP startup config 重放失败风险 🟠:如果用户保存了一个 effort 值,但该 ACP provider 重启后不再通过 configOptions 暴露 thought_level category,applyStartupConfig 会抛异常,session 无法建立。这种「存了合法值但 provider 改了 schema 就挂」的场景目前没有 graceful degradation 路径,合并前需确认是否可接受。

13验收提示

14覆盖声明

本报告基于对以下文件的全量精读(Read):agent/service.tsagent/types.tsagent/runner-manager.tsproviders/claude/reconfigure.tsproviders/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 的视野内,无抽样略读。