- 人工智能
- AI Agent
- 自主智能体
- 桌面应用
- MCP Clients
【免费下载链接】Kun
Local-first AI agent workspace for coding, writing, design, research, and automation — one runtime for desktop GUI and TUI.
导读
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、外部终端)重启在语义上截然不同。
原设计中描述的故障序列如下:
- Runtime 宿主 shell 对同一实例发起 stop/restart;
- 关闭过程中,租约客户端先丢弃本地租约地图,之后才挂起活动 turn;
- 因此后续的租约释放(release)变成 no-op(本地已无租约可释放);
- 新 Runtime 启动,短暂观测到旧的 Manager 租约仍存在,跳过孤儿恢复;
- 租约到期后 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 在发起关闭之前,需要:
- 用该 marker 与目标 discovery 记录中的
instanceId比较——即使目标健康端点不可用也要比较; - 在关闭请求发出前的最后一步,再对最终 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()语义为:
- 置为
closing:阻止新的 acquire、停止续租调度(stopAllRenewals); - 等待在途 acquire(
pendingAcquires)结算; - 对"迟到"的 acquire 结果立即释放;
- 按**完整租约代(lease generation)**去重普通释放与关闭释放;
- 并行排空剩余租约;
- 全部完成后置为
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 关闭顺序为:
- 先关闭 TurnService 的准入闸门(admission gate),等待在途的 start、steer、Graph-resume 变更离开其准入临界区;
- 停止 resume 调度器,quiesce Graph worker;
- 以
releaseLease: false挂起 Direct 与 Graph turn; - 停止 Graph;
- 在既有的 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, ... } = turnprojectTimelineTurn还根据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' ) }恢复流程的纪律性要求:
- 只有 thread 处于
idle、relation 为 primary/fork、无 queued/running turn 时才是候选; - 只有确定性的中断检查点(interruption checkpoint)持久化完成或已存在,thread 才变为可恢复(resumable);冲突与存储错误一律 fail-closed;
- 对账把精确的 thread/turn 源传递到路由层,丢弃父 turn 不是该源的 child-recovery 证据,并在续跑准入时原子性地要求该源仍是"最新失败 turn";
- 有活动 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.
相关推荐
LibreChat备份恢复:数据持久化与灾难恢复的可靠性设计
LibreChat备份恢复:数据持久化与灾难恢复的可靠性设计 引言:为什么数据备份如此重要? 在AI对话应用日益普及的今天,用户与AI的每一次交互都可能包含宝贵
人工智能大模型AI 应用交互助手如何在WordPress中安装WP-Editor.md?5分钟快速上手指南
如何在WordPress中安装WP Editor.md?5分钟快速上手指南 WP Editor.md是WordPress平台上最好、最完美的Markdown编辑
CMS插件系统后端Horovod故障恢复机制:训练中断自动恢复的可靠性设计
Horovod故障恢复机制:训练中断自动恢复的可靠性设计 引言:分布式训练的痛点与解决方案 在深度学习模型训练过程中,分布式训练已经成为处理大规模数据和复杂模型
深度学习机器学习分布式训练
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考