Maka Computer Use 运行时生命周期加固:从 stop 墓碑到代际释放的完整修复实录
2026/9/18 7:02:44 网站建设 项目流程

Maka Computer Use 运行时生命周期加固:从 stop 墓碑到代际释放的完整修复实录

【免费下载链接】makaApache Maka (Incubating) is a high-performance agent workspace that keeps a complete record of everything it did.项目地址: https://gitcode.com/GitHub_Trending/mak/maka

本指南基于 Maka 开源仓库中的设计评审文档 computer-use-runtime-hardening.md,系统梳理 Computer Use 运行时在 PR #892 评审后暴露出的生命周期缺陷、根因与修复方案,并结合 packages/runtime 与 packages/computer-use 的源码实现与测试用例进行纵深解析。读完本文,你将理解:为什么"清理"不能只清理已存在的状态记录、为什么只读操作也必须持有会话租约、为什么释放原因不足以推断执行器代际是否更替,以及一套可落地的"终态吸收 + 租约围栏 + 显式代际事实"加固范式。

背景:一次评审暴露出的生命周期缺口

Computer Use 是 Maka 中让 Agent 直接操作真实屏幕(观察窗口、点击元素、发送按键)的能力层。它由两层组成:Runtime 侧的会话状态机负责面向模型的语义围栏(如user_stoppedblocked_url),以及@maka/computer-use包内的执行器服务(MakaCuService)负责与原生maka-cu执行器子进程的 JSON-RPC 通信。

在 PR #892 的评审中,审查者发现该运行时存在一批"清理与围栏"方向的缺口:会话清理、只读操作、生命周期事件覆盖、执行器队列作用域、元素身份歧义、光标结果测试与释放原因语义。这些缺口单独看都是边角问题,但组合起来会形成真实的竞态:用户点了停止,被排队的调用却依然在清理之后激活执行;一个会话被清理,却可能连带丢弃另一个无关会话的观察结果与键盘所有权。

本文按"问题 → 根因 → 修复 → 验证"的脉络完整还原这次加固。

一、修复前的问题清单:七类生命周期缺陷

原文档列出的问题,可以归为七类,每一类都对应一个具体竞态:

  1. stop 墓碑缺失clearSession()在尚不存在 session-state 记录时不创建停止墓碑(tombstone),因此清理之后,首个被排队的调用仍可能激活执行。也就是说,"停止"这个事实没有被记录到可以被排队调用读取的地方。

  2. 只读操作无租约:只读的 host 动作(如观察窗口、等待条件)不获取会话租约(session lease),在用户已经user_stopped之后仍可能继续执行——只有"观察"与"变更"两类操作被认为需要生命周期围栏,这是根因之一。

  3. 终态可被覆盖:后续到达的生命周期事件(如一次新的observe成功、一次screen_unlocked)可能把blocked_urluser_stopped这样的终态覆盖掉,导致"已被禁止/已被停止"的会话被重新激活。

  4. 队列作用域错误:进程级(process-wide)的 executor 队列会阻塞无关窗口;而第一版按会话(per-session)替换又走向另一极端——允许两个会话对同一个窗口交错执行 snapshot/validation/dispatch。

  5. 歧义门混入临时 ID:歧义判定(ambiguity gate)把临时元素 ID(ephemeral element IDs)也纳入候选,即便当时已经存在稳定的元素身份(stable element identity),造成不必要的失败关闭(fail closed)。

  6. 光标结果无生产来源cursor_position的格式化逻辑存在,但没有任何生产后端的真实结果支撑,属于"格式先行、数据缺席"。

  7. 释放原因语义混淆:服务释放原因没有区分"仅会话通知"与"真实的子代际释放"。clearSession(A)可能因为释放事件携带的会话列表覆盖到 B,而丢弃会话 B 保留的观察结果与键盘所有权。

二、根因分析:围栏视野过窄,状态与代际事实不足

原文档对根因的剖析非常简洁,但直指要害:

  • 租约围栏覆盖不足:Runtime 把"观察租约"与"变更租约"当作仅有的两类需要生命周期围栏的操作,而"等待""读取"等 host 动作被遗漏;同时清理逻辑只对已创建的状态记录做变更,无法在状态记录尚未创建时就留下停止痕迹。

  • 终态与非终态共用同一迁移助手:可恢复状态(如reobserve_requiredscreen_locked)与终态(blocked_urluser_stopped)走同一个无限制的 transition helper,导致终态被后来的可恢复迁移覆盖。

  • 队列作用域绑错了对象:executor 队列的作用域是调用者(caller)而不是被验证的资源(bound window),因此出现"全局阻塞"或"同窗交错"两个方向的错误。

  • 身份签名混合:歧义签名把稳定身份与 snapshot 局部 ID 混在一起,无法区分"同一个控件换了标签"与"控件根本没变"。

  • 代际推断不可靠:执行器服务从释放原因(release reason)推断代际是否更替。但在无在途请求(no-in-flight)的clearSession()路径上,只发出session_cleared通知而不停止子进程——因此"释放原因"本身无法证明共享的执行器状态发生了变化。换句话说:session_cleared既可能伴随代际更替,也可能不伴随,单看原因是推断不出来的。

三、修复方案与源码级实现

原文档给出七条修复措施,下面逐条对应到当前仓库的源码实现。

3.1 无条件创建同轮 stop 墓碑

修复:clearSession()期间无条件创建同轮(same-turn)停止墓碑。

在 computer-use-tools.ts 中,tools.clearSession的实现正是这一语义:

tools.clearSession = (sessionId: string) => { if (invocationQueues.has(sessionId)) { presentationGenerations.set(sessionId, (presentationGenerations.get(sessionId) ?? 0) + 1); } for (const wake of presentationQueueWaiters.get(sessionId) ?? []) wake(); for (const wake of presentationWaiters.get(sessionId) ?? []) wake(); const current = sessionStates.get(sessionId); if (current) { current.state.userStopped(); } else { // 关键:状态记录不存在时,从待处理调用中取一个 turn, // 创建会话状态并立即写入 userStopped 墓碑 const pendingTurn = pendingInvocationTurns.get(sessionId)?.values().next().value; if (pendingTurn) sessionState(sessionId, pendingTurn).userStopped(); } invalidateObservation(sessionId); observations.delete(sessionId); deps.backend.clearSession?.(sessionId); };

要点在于else分支:即使sessionStates中还没有该会话的状态记录,只要存在待处理调用(pendingInvocationTurns),就通过sessionState()创建状态并写入userStopped()。这样,"停止"这个事实在同一轮内对后续排队的调用可见——排队调用在真正执行前会经过租约校验而被拦下,而不是在清理完成后悄悄激活。

测试用例 computer-use-tools.test.ts 中多个用例专门覆盖这条路径:

  • clearSession keeps a same-turn tombstone but a new turn can reopen:同一轮内调用被user_stopped拦截,而新的一轮可以重新打开会话(这正是原文档结尾"新的一轮仍然创建全新的会话状态,保留显式恢复边界"的验证)。
  • clearSession fences a first invocation that is already queued:首个排队调用在清理后同样被墓碑拦截。
  • clearSession fences a later turn that was queued before stop:停止前已排队的后续轮次同样被拦截。
  • clearSession after a non-CU turn does not block the next turn observe:反过来验证墓碑不会误伤下一个正常轮次的观察。

3.2 只读 host 动作也必须持有观察租约

修复:每个 host 读取或等待动作都必须先获取观察租约。

会话状态机 cua-session-state.ts 中定义了完整的租约模型:

export interface CuaActionLease { sessionId: string; generation: number; } beforeAction(): CuaActionLeaseResult { return this.status === 'active' ? { ok: true, lease: { sessionId: this.sessionId, generation: this.generation } } : { ok: false, reason: blockReason(this.status) }; } beforeObservation(): CuaObservationLeaseResult { return this.canObserve() ? { ok: true, lease: { sessionId: this.sessionId, generation: this.generation } } : { ok: false, reason: blockReason(this.status) }; }

观察租约与动作租约的差异在于允许的初始状态集合:canObserve()允许unobservedactivereobserve_required,而beforeAction()只在active时放行。这说明"观察"的准入面比"变更"宽(你总得先看才能动),但两者都返回{ sessionId, generation }代际绑定的租约。

在工具层 computer-use-tools.ts 中,observe路径会先取租约:

const lease = state.beforeObservation(); if (!lease.ok) { stopped = lease.reason; ... }

并且在观察完成后、以及在element_sequence等跨步骤操作中,都会用validateObservationLease(lease)二次校验租约是否仍然有效(例如 computer-use-tools.ts)。这样,即使观察请求在排队期间用户按下了停止,等它真正执行时,generation已因userStopped()的迁移而递增,租约校验失败,调用以user_stopped拒绝——只读操作再也不能在停止后继续

测试clearSession fences host-reading results that complete after stopclearSession fences failed host-reading results that complete after stop(见 computer-use-tools.test.ts)专门验证:在停止之后才完成的 host 读取结果(无论成功或失败)都必须被user_stopped围栏拦截。

3.3 终态吸收后续事件:blocked_urluser_stopped不可覆盖

修复:blocked_urluser_stopped吸收(absorb)后续生命周期事件。

这一条在 cua-session-state.ts 中有直接体现。状态机先定义终态集合,再让所有可恢复迁移在终态下变成 no-op:

private isTerminal(): boolean { return this.status === 'blocked_url' || this.status === 'user_stopped'; } blockedUrlDetected(): CuaSessionSnapshot { if (this.isTerminal()) return this.snapshot(); // 终态下不迁移 return this.transition('blocked_url'); } userStopped(): CuaSessionSnapshot { if (this.isTerminal()) return this.snapshot(); // 终态下不迁移 return this.transition('user_stopped'); } screenLocked(): CuaSessionSnapshot { if (this.isTerminal()) return this.snapshot(); return this.transition('screen_locked'); } freshObservationSucceeded(): CuaSessionSnapshot { if (!this.canObserve()) return this.snapshot(); return this.transition('active'); }

canObserve()只允许unobserved/active/reobserve_required,而blocked_urluser_stopped不在其中——因此一次新的观察成功、一次屏幕解锁、一次重新观察要求,都无法把终态会话拉回activeblockedUrlDetected()/userStopped()内部的isTerminal()守卫则保证了两个终态互相之间也不覆盖:一旦进入任一终态,后续任何生命周期事件都只返回当前快照(snapshot()不递增 generation、不改变 status)。

工具层配套的模型可见文案定义在 computer-use-tools.ts 的SESSION_BLOCK_RECOVERY中:

blocked_url: 'this target is refused for the rest of this session, and so is every other one — ...', user_stopped: 'the user stopped computer use for this session. Do not send any further computer action; ' + 'report that it was stopped.',

并且由applyTypedOutcomeState(computer-use-tools.ts)把后端的blocked_url/screen_locked/user_intervened等结果映射为状态机迁移,从而把"屏幕上的事实"沉淀为"会话状态"。

3.4 变更操作按绑定 PID/窗口串行化

修复:变更操作按绑定的 PID/窗口串行化;未绑定(unbound)的变更使用一个保守队列;无关的绑定窗口仍可独立推进。

原设计是进程级全局队列——一个窗口的慢操作会阻塞所有窗口;第一版 per-session 替换又允许两个会话对同一窗口交错执行 snapshot/validation/dispatch。修复方向是以被验证的资源(绑定窗口)为队列键:同一窗口的变更严格串行,不同窗口互不阻塞。

值得说明的是,在执行器层,@maka/computer-use的后续加固文档 computer-use-executor-hardening.md 记录了一个审慎的结论:进程级操作队列在 executor 层暂时保持全局,原因在于 Maka 当前只持有"一个 action-child stdio 连接",fresh-snapshot/action 对必须在该共享连接上保持原子;真正的并发需要引入独立的服务连接。这体现了"以资源为键的串行化"与"共享连接约束"之间的权衡——host 层按窗口分队列、executor 层在单一连接上保守串行,两层各守各的边界。

在 maka-cu-backend.ts 中,withOperationQueue的实现(queueKey = '__executor__')用一条 Promise 链保证同一 executor 上同一时刻只有一个操作在途,同时在操作真正开始前检查会话代际(active.generation !== sessionGeneration时抛出MakaCuSessionCleared),把"排队期间会话被清理"变成显式的拒绝路径。

3.5 稳定元素身份优先,临时 ID 不进歧义门

修复:存在稳定元素身份时,忽略临时元素 ID。

歧义门(ambiguity gate)用于判定"观测到的控件是否唯一对应模型要操作的目标"。旧实现把 snapshot 局部的临时元素 ID 也纳入候选集合,导致即便已经具备稳定的身份信息(role + label + value 等)也会因为临时 ID 的干扰而错误地失败关闭。

@maka/computer-use的语义加固(computer-use-executor-hardening.md)进一步收紧了这条规则:语义重取(semantic refetch)现在要求一个唯一的 role/label/value 候选,并验证其 frame、depth、value 仍与观测到的控件一致;同标签替换或候选集歧义一律失败关闭;原生内容指纹(content fingerprint)包含 label 与 value,因此同一结构槽位上控件含义的变化会使坐标动作失效。也就是说,身份判定从"有候选就行"演进为"唯一且可验证"。

在 maka-cu-backend.ts 中,modelIds映射(模型看到的短 ID → 线上 token)专门处理"模型引用的 ID"与"线上绑定 token"的分离——token 长达 53 字符且同快照共享 45 字符前缀,直接交给模型会导致复制失败;映射保证了模型面对的是稳定、可复制的短 ID,而线上绑定仍使用完整的稳定 token。

3.6 光标位置读取走固定驱动

修复:通过固定的 cua-driver 读取get_cursor_position,返回解析后的坐标点而不移动指针。

原文档指出cursor_position的格式化逻辑存在但没有生产后端结果(测试只用 fake backend)。修复要求把光标位置读取固定到 pinned cua-driver 上,返回值是解析后的坐标点,且不产生指针移动副作用——即"读取"必须是纯粹的观测,不能改变屏幕状态,否则就会与"只读操作也需围栏"的原则冲突。这与本文 3.2 的租约原则一致:host 读取动作要么持有租约,要么是纯函数式读取,二者缺一不可。

3.7 显式携带generationReleased释放事实

修复:把generationReleased作为显式的服务释放事实携带。仅会话通知(session-only)的释放只使列出的会话失效;真实的子进程退出(child exit)才清扫所有保留的会话观察与键盘目标。

这是整篇加固中最核心的语义修正。旧实现从释放原因推断代际更替,但session_cleared在无在途请求时并不停止子进程,因此"原因"无法作为代际事实。

在 maka-cu-service.ts 中,释放事件被显式建模:

export interface MakaCuReleaseEvent { generation: number; generationReleased: boolean; // 显式代际事实 reason: | 'child_exit' | 'request_timeout' | 'protocol_violation' | 'session_cleared' | 'restart_exhausted' | 'disposed'; sessionIds: readonly string[]; outcomeUnknown: boolean; }

两个发射点给出了不同的事实:

  • clearSession()(maka-cu-service.ts):对会话在途请求逐个发起$/cancel,随后emitRelease('session_cleared', [sessionId], owned.length > 0, false)——generationReleased = false,因为子进程没有被停止,共享的执行器状态(代际)没有变化,只是该会话被放弃。
  • onExit()(maka-cu-service.ts):任何子进程退出路径(child_exit/request_timeout/protocol_violation/ 超时强杀)都调用emitRelease(reason, sessionIds, potentiallyDelivered.length > 0, true)——generationReleased = true,因为子进程的代际确实终结了。

消费侧 maka-cu-backend.ts 的applyServiceRelease据此分派:

function applyServiceRelease(events: readonly MakaCuReleaseEvent[]): void { const generationReleased = events.some((event) => event.generationReleased); const sessions = [ ...new Set([ ...events.flatMap((event) => event.sessionIds), // 代际释放:上一代的所有会话与快照全部失效 ...(generationReleased ? begunSessions : []), ...(generationReleased ? [...snapshots.values()].map((snapshot) => snapshot.sessionId) : []), ]), ]; ... }

只有generationReleased为真时,begunSessions与所有快照所属会话才会被一并清扫——这正是"真实子进程退出清扫所有保留观察与键盘目标"。而clearSession(A)产生的 session-only 释放只会让 A(以及事件中显式列出的会话)失效,无关会话 B 的观察与键盘所有权不受影响

对应测试 maka-cu-backend.test.ts(ends cleared sessions without re-notifying them and invalidates known sessions on generation loss)完整验证了这条语义:

  • clearSession后执行器确实收到session.end(通过日志记录等待该消息);
  • 对已清理会话再次clearSession、以及对从未开始的会话clearSession,都不会重复通知观察者("unknown cleanup must not notify observers");
  • 已完成的会话仍持有 begun 状态,当执行器被SIGKILL导致代际丢失时,onSessionInvalidated会准确收到该会话的失效回调——即使它的操作围栏早已释放。

四、验证方式

原文档给出四项验证命令,与当前仓库的包结构完全对应:

# Runtime 类型检查 npm --workspace @maka/runtime run typecheck # Runtime 单元测试(含上述 clearSession 墓碑、租约围栏用例) npm --workspace @maka/runtime test # Computer Use 后端测试(含代际释放、session.end、键盘目标回归用例) npm --workspace @maka/computer-use test # 变更检查 git diff --check

后续的 executor 加固(computer-use-executor-hardening.md)记录的聚焦测试套件达到 111 个用例,覆盖语义替换、注册表不匹配、观察淘汰、窗口压缩、共享客户端排序、生命周期错误与键盘目标回归等场景,与本文的生命周期加固互为补充。

五、总结:一套可复用的运行时加固范式

回顾整个修复,核心可以提炼为四条原则,这也是任何"Agent 操作真实资源"的运行时都值得借鉴的:

  1. 停止必须是先验事实,而不是事后动作clearSession在状态记录缺失时也要留下同轮墓碑,让排队中的调用在真正执行前就能读到"已被停止"。
  2. 租约围栏覆盖所有操作类别。不只是变更需要租约,读取与等待同样需要;租约绑定{sessionId, generation},代际递增即事实变更。
  3. 终态不可被恢复事件覆盖blocked_urluser_stopped一旦进入,后续任何生命周期事件都被吸收(absorb),杜绝"已禁止/已停止"的会话被意外复活。
  4. 代际事实必须显式,不能从原因推断generationReleased作为独立字段随释放事件携带,session-only 释放与真实子进程退出严格分流,避免一个会话的清理误伤另一个无关会话。

从 cua-session-state.ts 的状态机、computer-use-tools.ts 的工具层围栏,到 maka-cu-service.ts 的释放事件建模与 maka-cu-backend.ts 的消费分派,再到两份加固文档与配套测试,Maka 为"Agent 控制屏幕"这个高风险场景给出了一个边界清晰、可验证的生命周期治理样例——新的一轮对话仍然会创建全新的会话状态,显式恢复边界由此得以保留。

【免费下载链接】makaApache Maka (Incubating) is a high-performance agent workspace that keeps a complete record of everything it did.项目地址: https://gitcode.com/GitHub_Trending/mak/maka

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

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

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

立即咨询