☰
Kun Runtime 自重启租约恢复(Lease Recovery)设计解析:从自我控制边界到可恢复的持久化终结分类
2026/10/12 3:32:52 网站建设 项目流程
  • 人工智能
  • AI Agent
  • 自主智能体
  • 桌面应用
  • MCP Clients

【免费下载链接】Kun

Local-first AI agent workspace for coding, writing, design, research, and automation — one runtime for desktop GUI and TUI.

项目地址:https://gitcode.com/gh_mirrors/de/Kun
点击查看免费下载

导读

Kun 是一个 Local-first 的 AI Agent 工作台,其桌面 GUI 与 TUI 共享同一个 Runtime,而 Runtime 又由 Service Manager 统一监管。当 Runtime 宿主内的 Agent shell 调用kun runtime restart自我重启时,会出现一个隐蔽的竞态:旧 Runtime 在活动 turn 挂起之前就丢弃了本地租约(lease)地图,导致后续的 release 变成 no-op;新 Runtime 启动后短暂观测到旧的 Manager 租约而跳过孤儿恢复,最终在租约过期后由 Manager 写入owner_lease_expired,使进行中的 goal 永久搁浅。本文以 openspec/changes/fix-runtime-self-restart-lease-recovery/design.md 为骨架,结合仓库源码与测试,系统讲解这一修复方案的五大核心决策:精确 Runtime 身份自控边界、租约关闭异步释放屏障、活动 turn 所有权保留、可恢复的持久化终结分类,以及渲染端显式 null 的权威语义。读完本文,你将理解 Kun 如何在 CLI、shell 环境过滤、租约客户端生命周期、Runtime 关闭顺序、turn/goal 持久化、启动对账与渲染端状态处理七个维度上,把"自我重启"从数据损坏风险变成可安全恢复的正常事件。

问题背景:自我重启为什么会导致 goal 搁浅

Kun 的架构中,Service Manager 是 thread 状态的唯一写者,持有带 fencing 的执行租约;Runtime 负责执行 turn 并续租。关键约束包括:

  • 单一写者:Manager 独占 thread 状态写入权限,任何无有效 mutation fence 的写入都会被拒绝;
  • 租约即栅栏:执行租约携带 fencing token,是 Runtime 对某 thread 执行权的权威凭证;
  • 重启路径特殊:Runtime 宿主内的 shell 可以对自己的同一个实例发起kun runtime restart,这是一条"自我控制"路径,与外部(GUI、外部终端)重启在语义上截然不同。

原设计中描述的故障序列如下:

  1. Runtime 宿主 shell 对同一实例发起 stop/restart;
  2. 关闭过程中,租约客户端先丢弃本地租约地图,之后才挂起活动 turn;
  3. 因此后续的租约释放(release)变成 no-op(本地已无租约可释放);
  4. 新 Runtime 启动,短暂观测到旧的 Manager 租约仍存在,跳过孤儿恢复;
  5. 租约到期后 Manager 写入owner_lease_expired,活动 goal 因此搁浅,无法自动恢复。

从源码看,这一问题的根源在租约客户端的状态机与关闭时序。见 kun/src/manager/manager-thread-execution-lease-client.ts:ManagerThreadExecutionLeaseClient用state: 'open' | 'closing' | 'closed'表达生命周期,而修复方案正是把这个客户端升级为"异步释放屏障"。

修复横跨七个层面:CLI 身份、shell 环境过滤、租约客户端生命周期、Runtime 关闭顺序、turn/goal 持久化、启动对账、渲染端 canonical 状态处理。Manager 的 fencing 与既有重启授权仍然是安全边界——本方案不削弱信任边界,只把"自我重启"纳入受控恢复轨道。

目标与非目标

目标(Goals)

  • 仅拒绝来自 Agent 受控执行(agent-controlled execution)的、对同一实例的 Runtime stop/restart 请求;
  • 在 Runtime 注销(unregister)之前释放每一个已持有的 Manager 执行租约,并在 Manager 确认释放前保持 fencing 生效;
  • 在优雅挂起释放所有权之前,持久化精确测量的 goal 已用时长(elapsed time);
  • 对owner_lease_expired中断做分类与 exactly-once 恢复,兼容terminalCode持久化终结码出现之前创建的旧记录;
  • 保持 goal/todo 投影(projection)为 canonical 状态,并提供安全的手动恢复兜底。

非目标(Non-Goals)

  • 不延长租约 TTL,也不接受没有合法 mutation fence 的写入;
  • 不引入通用的活动 turn 重启禁令、不新增强制(force)标志,不改变受信的外部/GUI 重启授权;
  • 不重新引入第二个 Runtime、遗留 provider/process-manager 表面、Runtime 诊断 UI 或渲染端驱动的自动恢复;
  • 不把重启进程清理扩展到普通启动、watchdog、updater 或 GUI 退出路径;
  • 不为 HTTP 路由、SSE 事件或 Manager 租约协议 DTO 增加版本化;
  • 不在硬崩溃或 Runtime 停机期间估算已用时长。

这套边界非常克制:修复只触及"自我控制重启"这一条窄路径,其余重启/退出路径的行为保持不变,因此回归风险被限制在可控范围。

决策一:精确 Runtime 身份是自我控制的边界

用实例 ID 而非 PID 做自我判定

Runtime 已经持有KUN_RUNTIME_INSTANCE_ID环境变量。方案要求:Agent shell 的安全环境 allowlist 只暴露这个非机密的实例标识符(它不是授权密钥,而是偶然的自我控制守卫);真正的安全边界仍是既有的认证生命周期控制。

CLI 的 stop/restart 在发起关闭之前,需要:

  1. 用该 marker 与目标 discovery 记录中的instanceId比较——即使目标健康端点不可用也要比较;
  2. 在关闭请求发出前的最后一步,再对最终 inspected 记录重复一次比较(防止发现记录中途漂移)。

若二者相等,返回退出码 70与稳定错误码runtime_self_control_forbidden;缺失或不等则保持外部控制行为不变。

源码佐证

kun/src/cli/shared-runtime-command-guard.ts 中的assertRuntimeSelfControlAllowed实现了这一比较:

export function assertRuntimeSelfControlAllowed( inspected: SharedRuntimeInspection | null, callerRuntimeInstanceId: string | undefined ): void { if (!callerRuntimeInstanceId || inspected?.discovery.instanceId !== callerRuntimeInstanceId) return throw new Error( 'runtime_self_control_forbidden: cannot stop or restart the Runtime that is executing ' + 'this command; use the GUI or an external terminal' ) }

配套的assertOneShotRuntimeControlAllowed在自我控制校验之上叠加了clientOwnerKind检查——若 discovery 记录表明 runtime 已被某客户端拥有,则拒绝"一键式"控制,提示改用关闭/重启该客户端。而sameInspectedRuntimeOwner对 instanceId、pid、startedAt、host、port、baseUrl、runtimeToken、launchMode、clientOwnerKind、flavor 做全字段比对,用于判断两次 inspection 是否指向同一 Runtime。

测试见 kun/src/cli/shared-runtime-command.test.ts:测试通过env: { KUN_RUNTIME_INSTANCE_ID: record.instanceId }模拟自我调用场景,断言 stderr 包含runtime_self_control_forbidden,并验证缺失该环境变量时({})走外部控制路径。

为什么不用活动 turn 门禁或 PID 匹配

  • 活动 turn 门禁:受信的外部重启有意允许打断并恢复工作,因此不能用"有活动 turn 就拒绝"的粗粒度门禁;
  • PID 匹配:discovery 的实例身份(instanceId)才是权威的生命周期身份。Runtime 重启后 PID 会变化,而 instanceId 在两次 inspection 之间保持稳定可比。

决策二:租约关闭是一个异步释放屏障

状态机升级:open / closing / closed

ThreadExecutionLeasePort.shutdown升级为Promise<void>,租约客户端的关闭从"尽力而为"变成"可等待的释放屏障"。见 kun/src/ports/thread-execution-lease.ts:

export interface ThreadExecutionLeasePort { acquire(threadId: string, turnId: string): Promise<ThreadExecutionLease> release(threadId: string, turnId: string): Promise<void> owner(threadId: string): Promise<ThreadExecutionLease | null> authorityState?(threadId: string): ThreadLeaseAuthorityState waitAuthorityResolution?(threadId: string): Promise<'holder' | 'lost'> setLeaseLostHandler?(handler: (lease: ThreadExecutionLease) => void): void shutdown?(): Promise<void> }

实现 kun/src/manager/manager-thread-execution-lease-client.ts 定义了type LeaseClientState = 'open' | 'closing' | 'closed'与单例的shutdownPromise,其shutdown()语义为:

  1. 置为closing:阻止新的 acquire、停止续租调度(stopAllRenewals);
  2. 等待在途 acquire(pendingAcquires)结算;
  3. 对"迟到"的 acquire 结果立即释放;
  4. 按**完整租约代(lease generation)**去重普通释放与关闭释放;
  5. 并行排空剩余租约;
  6. 全部完成后置为closed。

关键实现点:

  • lease generation 去重:leaseGenerationKey由threadId + turnId + ownerInstanceId + fencingToken组成,sameLeaseGeneration做全字段比对。只有同一代租约的释放才会清除leasesByTurn缓存,避免旧代的迟到响应清掉新代的所有权;
  • release 幂等:release()内部先查pendingReleases,若该代租约已有在途释放则直接复用同一个 Promise(deduplicate);
  • {released:false} 视为幂等成功:Manager 返回"已释放或本就不存在"都算释放成功;
  • 有界重试:LEASE_RELEASE_ATTEMPTS = 3次重试仍以传输失败告终时,该代租约的 fail-closed fence 保持安装,不影响其他租约释放,最终汇总为一个聚合关闭错误(AggregateError,消息为one or more thread execution leases could not be released during shutdown);
  • 同线程同 turn 的 acquire 等待在途 release(pendingTurnReleases):否则 Manager 可能把仍然当前的 fencing token 返回回来,而 generation-aware 比较会防止不同代的延迟响应清除更新的所有权。
async acquire(threadId: string, turnId: string): Promise<ThreadExecutionLease> { if (this.state !== 'open') throw new Error('thread execution lease client is shutting down') const pending = this.acquireOpen(threadId, turnId) this.pendingAcquires.add(pending) try { return await pending } finally { this.pendingAcquires.delete(pending) } }

acquireOpen内先等待同 key 的在途释放,再向/v1/leases/threads/{threadId}/acquire发起请求;若响应期间状态已非open,立即释放新获取的租约并抛出关闭错误——这正对应设计文档中"迟到 acquire 立即释放、closing 状态不得重装定时器或 fence"的规则。renew()与recordRenewal()同样以this.state !== 'open'为守卫,保证关闭中的迟到续租响应不会重装续租定时器。

finishShutdown用Promise.allSettled并行排空pendingReleases与剩余leasesByTurn,收集所有拒绝原因;owner()也会先等待本地释放确认,避免调度器把刚释放的缓存 fence 发给 Manager。

设计文档明确指出:不需要独立的公共 drain 方法,也不需要修改 Manager 协议——屏障完全在客户端内部实现。

决策三:活动 turn 在挂起状态持久化之前保留所有权

Runtime 关闭的严格顺序

修复后的 Runtime 关闭顺序为:

  1. 先关闭 TurnService 的准入闸门(admission gate),等待在途的 start、steer、Graph-resume 变更离开其准入临界区;
  2. 停止 resume 调度器,quiesce Graph worker;
  3. 以releaseLease: false挂起 Direct 与 Graph turn;
  4. 停止 Graph;
  5. 在既有的 active-run 时限(bound)内等待。

关键设计点:

  • goal 与普通自动续跑走同一 host-owned run tracker:goal continuation、普通自动续跑与 HTTP、Graph、review、extension turn 共用同一跟踪器,因此它们的挂起清理也在同一时限内完成;
  • 挂起的 AgentLoop 清理调用goalTurns.afterSuspended:它只对同一 goal generation的当前可靠计时器做最终化(finalize),从而在优雅挂起释放所有权之前持久化精确测量的 goal 已用时长;
  • 普通终结清理与 Graph parking 仍默认释放租约:releaseLease: false只作用于活动 turn 的挂起路径;
  • 关闭阶段独立收集失败:挂起失败、Graph-stop 失败或 active-run 拒绝都不会跳过后续租约排空;
  • 时限内未解开的 run 不再估算已用时长,直接继续关闭;释放失败被报告但其余清理照常进行,Manager 到期(expiry)仍然是 fail-closed 兜底。

测试佐证

kun/src/loop/agent-loop-host-shutdown.test.ts 用shutdown-aware-model与executionLeaseHarness验证:Direct、Graph、Planning 三种关闭恢复路径下,leaseHarness.release不应被调用(因为以releaseLease: false挂起,所有权保留到挂起状态持久化之后);Planning 路径还断言"host shutdown 不得变更 durable 草稿状态";另有针对 release 取消的releaseCancellation场景测试。

决策四:可恢复性 = 持久的终结分类

terminalCode 与 managerLeaseSettlement 双字段

Turn 的可选字段terminalCode存储有界的机器可读终结原因(不使既有会话数据失效);当 Manager 直接判定某个 turn 失败时,同时写入内部managerLeaseSettlement溯源数据,包含 owner 实例、flavor、fencing token 与结算时间。

契约定义见 kun/src/contracts/turns.ts:

/** Manager-authored proof that an execution owner expired for this turn. */ export const ManagerLeaseSettlementSchema = z.object({ code: z.literal('owner_lease_expired'), ownerFlavor: z.enum(['production', 'development']), ownerInstanceId: z.string().min(1).max(256), fencingToken: z.number().int().positive(), settledAt: z.string().datetime() }).strict() // TurnSchema 内: /** Optional stable machine-readable reason for a terminal turn. */ terminalCode: z.string().trim().min(1).max(128).optional(), /** Internal Manager-authored ownership-expiry provenance. */ managerLeaseSettlement: ManagerLeaseSettlementSchema.optional(),

在 Manager 侧,kun/src/manager/shared-data-store-core.ts 的结算逻辑会:

  • 追加 canonical 错误项item_{turnId}_owner_lease_expired,消息为 "Turn owner stopped heartbeating.",code: 'owner_lease_expired'、severity: 'warning';
  • 将 turn 标记为failed,写入terminalCode: 'owner_lease_expired'与完整managerLeaseSettlement;
  • 追加turn_failed事件并更新 thread 状态。
turns: thread.turns.map((turn) => turn.id === lease.turnId ? { ...finishTurn(turn, 'failed', now), terminalCode: 'owner_lease_expired', managerLeaseSettlement: { code: 'owner_lease_expired' as const, ownerFlavor: lease.ownerFlavor, ownerInstanceId: lease.ownerInstanceId, fencingToken: lease.fencingToken, settledAt: now }, error: ownerLeaseExpiredMessage(lease) } : turn)

投影剥离恢复专用字段

公共的 thread/turn 投影会移除这两个恢复专用字段,渲染端看到的信号仍是既有 canonical error item。见 kun/src/server/routes/thread-projection.ts:

const { terminalCode: _terminalCode, managerLeaseSettlement: _managerLeaseSettlement, ... } = turn

projectTimelineTurn还根据terminalCode === 'queue_cancelled'等状态决定是否暴露 turn items——投影层既隔离内部恢复数据,又保持渲染端语义稳定。

启动对账:Manager 结算驱动的恢复

Manager 注册新 Runtime 之前,先过期陈旧的 predecessor 租约并持久化对账每一个,然后才注册替代 Runtime。Runtime 的每一种启动模式都只扫描 Manager 证明的结算数据(在一个有界的启动窗口内):

  • 新记录:managerLeaseSettlement.code === 'owner_lease_expired'且settledAt >= settledAfter即视为可恢复;
  • 旧记录(两个新字段都不存在):仅当该 thread 最新失败 turn 存在精确确定性的 canonical 错误项(item_{turnId}_owner_lease_expired,kind=error、code=owner_lease_expired)且在窗口内,才被接受——绝不模糊匹配消息文本。

实现见 kun/src/services/turn-service-runtime-state-operations.ts 的reconcileManagerSettledInterruptions:

const managerSettlement = latest.managerLeaseSettlement let recoverable = Boolean( managerSettlement?.code === 'owner_lease_expired' && timestampAtOrAfter(managerSettlement.settledAt, input.settledAfter) ) if (!managerSettlement && latest.terminalCode === undefined) { sessionItems = await this['deps'].sessionStore.loadItems(thread.id) const canonicalItemId = `item_${latest.id}_owner_lease_expired` recoverable = timestampAtOrAfter(latest.finishedAt, input.settledAfter) && sessionItems.some((item) => item.id === canonicalItemId && item.turnId === latest.id && item.kind === 'error' && item.code === 'owner_lease_expired' ) }

恢复流程的纪律性要求:

  1. 只有 thread 处于idle、relation 为 primary/fork、无 queued/running turn 时才是候选;
  2. 只有确定性的中断检查点(interruption checkpoint)持久化完成或已存在,thread 才变为可恢复(resumable);冲突与存储错误一律 fail-closed;
  3. 对账把精确的 thread/turn 源传递到路由层,丢弃父 turn 不是该源的 child-recovery 证据,并在续跑准入时原子性地要求该源仍是"最新失败 turn";
  4. 有活动 goal 的 thread 走 goal continuation;无活动 goal 的 thread 即使保留着更早的 completed/paused/blocked/usage-limited goal 记录,也走既有普通续跑路径。

该文件还包含对另一类中断(orphaned_after_restart)的reconcileOrphanedRuns,用recordInterruptionCheckpoint持久化模型可见的检查点,使自动续跑的 turn 能从断点继续而无需用户重述 prompt。

测试见 kun/src/services/turn-service-manager-reconciliation.test.ts:覆盖"新记录带managerLeaseSettlement"、"旧记录仅有精确 canonical error item(无 terminalCode)"以及各类负例。

决策五:渲染端显式 null 保持权威

undefined 表示缺失、null 表示权威清空

Runtime 快照字段采用undefined兼容省略 goal/todos 的 provider,用null表示权威清空。因此投影与 prefetch 的兜底判断必须用=== undefined而不是 nullish 合并(??),避免把 null(权威清空)误当成缺失。

延迟响应的独立 fencing

延迟的 detail 响应必须独立 fencing 四重条件——sequence、tagged turn、detail latest turn、projected latest turn——之后才能替换较新的 goal、todo、sidebar 或 live-turn 状态;identity-stale 的 tagged 响应也无法推进 SSE cursor,因为它没有导入该高水位(high-water)代表的新事件。

渲染端 UI 语义

渲染端将owner_lease_expired本地化,并仅在以下条件同时满足时暴露 Continue 按钮:整条 thread 空闲、最新结算 turn 可见、父层提供主线程动作(main-thread action)。自动恢复始终由后端拥有,渲染端不做自动恢复驱动——这与此前的设计文档 openspec/specs/ 中"恢复逻辑集中在后端"的原则一致。

风险与权衡(Trade-offs)

设计文档明确列出五组风险及对应的缓解策略:

风险缓解
Manager 传输故障阻止干净释放排空每个租约、保留 fail-closed fencing、汇总聚合关闭错误、由既有过期对账安全恢复
迟到 acquire/renew 响应与关闭竞态跟踪在途 acquire、成功迟到 acquire 立即释放、closing 状态的响应禁止重装定时器/fence
启动对账重复续跑要求已提交的确定性检查点、调度绑定到精确的最新失败 turn、复用既有 cooldown/capacity 门禁
Provider 数据伪造恢复码新记录要求 Manager 署名的结算溯源;旧数据仅接受精确 canonical 项与有界启动窗口,绝不模糊匹配消息
内部 Turn schema 增加字段保持可选且有界;既有持久化 turn 与客户端仍然有效

迁移计划与回滚

无需一次性数据迁移:新失败会持久化terminalCode;启动对账透明地同时识别新记录与精确 canonical 的旧错误项。回滚方式为:撤销代码与可选字段读取器;已持久化的可选字段对旧 schema 无害。这保证了该修复可以安全地在运行中的部署中前滚与回滚,且新旧 Runtime 并存时互不破坏。

关联阅读与验证路径

  • 变更设计全文:openspec/changes/fix-runtime-self-restart-lease-recovery/design.md
  • 自我控制守卫:kun/src/cli/shared-runtime-command-guard.ts 及测试 kun/src/cli/shared-runtime-command.test.ts
  • 租约端口契约:kun/src/ports/thread-execution-lease.ts
  • 租约客户端状态机实现:kun/src/manager/manager-thread-execution-lease-client.ts
  • 终结分类契约:kun/src/contracts/turns.ts
  • Manager 结算写入:kun/src/manager/shared-data-store-core.ts
  • 启动对账实现:kun/src/services/turn-service-runtime-state-operations.ts 及测试 kun/src/services/turn-service-manager-reconciliation.test.ts
  • 投影剥离与渲染端语义:kun/src/server/routes/thread-projection.ts
  • Host 关闭挂起测试:kun/src/loop/agent-loop-host-shutdown.test.ts

从整体架构看,本修复是 Kun"Manager 单写者 + Runtime 执行者"模型的又一次收口:它没有扩大 Manager 的协议面,而是通过精确身份判定、异步释放屏障、持久化终结分类与启动对账,把"自我重启"从数据一致性事故转化为一次有检查点、可 exactly-once 恢复的普通中断事件。

  • 人工智能
  • AI Agent
  • 自主智能体
  • 桌面应用
  • MCP Clients

【免费下载链接】Kun

Local-first AI agent workspace for coding, writing, design, research, and automation — one runtime for desktop GUI and TUI.

项目地址:https://gitcode.com/gh_mirrors/de/Kun
点击查看免费下载

相关推荐

上一篇:终极RPG Maker解密教程:5分钟学会提取加密游戏资源
下一篇:30+个Adobe Illustrator脚本:设计师效率提升终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询