Skip to content

ACP 生命周期重构旨在解决进程清理误杀和会话可见性错乱问题,尤其适合多网关部署、子智能体或后台任务场景。通过引入 AcpSessionOwnerAcpxProcessLease 类型,确保进程清理只针对当前所属租赁记录,且 cancel 不会关闭可复用的会话。迁移分五阶段进行,旧会话记录兼容保留,无需手动清理状态。

OpenClaw ACP 生命周期重构 - 会话所有权与进程清理指南

当前 ACP 生命周期虽然工作,但过多依赖事后推导。进程清理从 PID、命令字符串、包装器路径和实时进程表回溯所有权;会话可见性从会话键字符串和二次 sessions.list({ spawnedBy }) 查询重建所有权。这使得窄修复可行,但容易遗漏边界情况:PID 重用、带引号的命令、适配器子进程、多网关状态根、cancelclose 的区别、treeall 的可见性都成为需要单独发现所有权规则的地方。

本次重构将所有权提升为一等公民。目标不是推出新的 ACP 产品功能,而是为现有 ACP 和 ACPX 行为提供更安全的内部契约。

目标

  • 清理时仅当当前实时证据匹配 OpenClaw 拥有的租赁记录才发送信号。
  • cancelclose 和启动收割具有不同的生命周期意图。
  • sessions_listsessions_historysessions_send 和状态检查使用相同的请求者拥有会话模型。
  • 多网关安装不能相互收割对方的 ACPX 包装器。
  • 迁移期间旧 ACPX 会话记录继续工作。
  • 运行时保持插件拥有;核心不学习 ACPX 包细节。

非目标

  • 替换 ACPX 或更改公开的 /acp 命令接口。
  • 将供应商特定的 ACP 适配器行为移入核心。
  • 要求用户在升级前手动清理状态。
  • 使 cancel 关闭可复用的 ACP 会话。

目标模型

网关实例身份

每个网关进程应有一个稳定的运行时实例 ID:

ts
type GatewayInstanceId = string;

它可在网关启动时生成并持久化到状态中,用于该安装的生命周期。它不是一个安全秘密;而是一个所有权区分器,用于避免混淆一个网关的 ACP 进程与另一个网关的 ACP 进程。

ACP 会话所有权

每个生成的 ACP 会话应有规范的所有权元数据:

ts
type AcpSessionOwner = {
  sessionKey: string;
  spawnedBy?: string;
  parentSessionKey?: string;
  ownerSessionKey: string;
  agentId: string;
  backend: "acpx";
  gatewayInstanceId: GatewayInstanceId;
  createdAt: number;
};

网关应在已知这些字段的会话行上返回它们。可见性过滤应是对行元数据的纯检查:

ts
canSeeSessionRow({
  row,
  requesterSessionKey,
  visibility,
  a2aPolicy,
});

这去除了可见性检查中隐藏的二次 sessions.list({ spawnedBy }) 调用。生成的跨智能体 ACP 子会话由请求者拥有,因为行这么说,而不是因为第二次查询碰巧找到了它。

ACPX 进程租赁

每个生成的包装器启动应创建一个租赁记录:

ts
type AcpxProcessLease = {
  leaseId: string;
  gatewayInstanceId: GatewayInstanceId;
  sessionKey: string;
  wrapperRoot: string;
  wrapperPath: string;
  rootPid: number;
  processGroupId?: number;
  commandHash: string;
  startedAt: number;
  state: "open" | "closing" | "closed" | "lost";
};

包装器进程应在其环境中接收租赁 ID 和网关实例 ID:

sh
OPENCLAW_ACPX_LEASE_ID=...
OPENCLAW_GATEWAY_INSTANCE_ID=...

当平台支持时,验证应优先使用不被命令引用混淆的实时进程元数据:

  • 根 PID 仍存在
  • 实时包装器路径在 wrapperRoot
  • 进程组在可用时匹配租赁
  • 环境在可读取时包含预期的租赁 ID
  • 命令哈希或可执行路径匹配租赁

如果无法验证实时进程,清理失败关闭。

生命周期控制器

引入一个 ACPX 生命周期控制器,拥有进程租赁和清理策略:

ts
interface AcpxLifecycleController {
  ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
  cancelTurn(handle: AcpRuntimeHandle): Promise<void>;
  closeSession(input: {
    handle: AcpRuntimeHandle;
    discardPersistentState?: boolean;
    reason?: string;
  }): Promise<void>;
  reapStartupOrphans(): Promise<void>;
  verifyOwnedTree(lease: AcpxProcessLease): Promise<OwnedProcessTree | null>;
}

cancelTurn 仅请求回合取消。它不得杀除可复用的包装器或适配器进程。

closeSession 允许收割,但仅在加载会话记录、加载租赁并验证实时进程树仍属于该租赁之后。

reapStartupOrphans 从状态中的 open 租赁开始。它可以使用进程表查找后代,但不应先扫描所有看起来像 ACP 的命令然后决定它们可能是我们的。

包装器契约

生成的包装器应保持小型。它们应:

  • 在支持的平台上以进程组启动适配器
  • 将正常终止信号转发到进程组
  • 检测父进程死亡
  • 父进程死亡时发送 SIGTERM,然后保持包装器存活直到 SIGKILL 回退运行
  • 在可用时向生命周期控制器报告根 PID 和进程组 ID

包装器不应决定会话策略。它们仅强制对其自身适配器组执行本地进程树清理。

会话可见性契约

可见性应使用规范化的行所有权:

ts
type SessionVisibilityInput = {
  requesterSessionKey: string;
  row: {
    key: string;
    agentId: string;
    ownerSessionKey?: string;
    spawnedBy?: string;
    parentSessionKey?: string;
  };
  visibility: "self" | "tree" | "agent" | "all";
  a2aPolicy: AgentToAgentPolicy;
};

规则:

  • self:仅请求者会话。
  • tree:请求者会话加上由请求者拥有或从请求者生成的会话。
  • all:所有同智能体会话、a2a 允许的跨智能体会话以及请求者拥有的生成跨智能体会话(即使常规 a2a 禁用)。
  • agent:仅同智能体,除非显式所有者关系表明该行属于请求者。

这使得 treeall 单调递增:all 不得隐藏 tree 会显示的拥有子会话。

迁移计划

第一阶段:添加身份和租赁

  • 向网关状态添加 gatewayInstanceId
  • 在 ACPX 状态目录下添加 ACPX 租赁存储。
  • 在生成包装器前写入租赁。
  • 在新的 ACPX 会话记录上存储 leaseId
  • 保留旧的 PID 和命令字段以兼容旧记录。

第二阶段:租赁优先清理

  • 更改关闭清理,首先加载 leaseId
  • 在发送信号前验证实时进程所有权是否匹配租赁。
  • 仅对遗留记录保留当前根 PID 和包装器根回退。
  • 在验证清理后将租赁标记为 closed
  • 当进程在清理前已消失时将租赁标记为 lost

第三阶段:租赁优先启动收割

  • 启动收割扫描 open 租赁。
  • 对每个租赁,验证根进程并收集后代。
  • 按子进程优先收割已验证的树。
  • 使用有界保留窗口过期旧的 closedlost 租赁。
  • 仅将命令标记扫描保留为临时遗留回退,并在可能时按包装器根和网关实例守卫。

第四阶段:会话所有权行

  • 向网关会话行添加所有权元数据。
  • 教导 ACPX、子智能体、后台任务和会话存储写入程序填充 ownerSessionKeyspawnedBy
  • 将会话可见性检查转换为使用行元数据。
  • 移除可见性时的二次 sessions.list({ spawnedBy }) 查找。

第五阶段:移除遗留启发式

在一个发布窗口后:

  • 停止依赖存储的根命令字符串进行非遗留 ACPX 清理
  • 移除命令标记启动扫描
  • 移除可见性回退列表查找
  • 对缺失或无法验证的租赁保持防御性的失败关闭行为

测试

添加两个表驱动的测试套件。

进程生命周期模拟器:

  • PID 被无关进程重用
  • PID 被另一个网关的包装器根重用
  • 存储的包装器命令带 shell 引用,实时 ps 命令不带
  • 适配器子进程退出,孙子进程留在进程组中
  • 父进程死亡 SIGTERM 回退到达 SIGKILL
  • 进程列表不可用
  • 过期租赁且进程缺失
  • 启动孤儿带包装器、适配器子进程和孙子进程

会话可见性矩阵:

  • selftreeagentall
  • a2a 启用和禁用
  • 同智能体行
  • 跨智能体行
  • 请求者拥有的生成跨智能体 ACP 行
  • 沙盒请求者限制为 tree
  • list、history、send 和 status 操作

重要不变量:请求者拥有的生成子会话在配置的可见性包含请求者会话树时始终可见,且 all 能力不低于 tree

兼容性说明

旧会话记录可能没有 leaseId。它们应使用遗留的失败关闭清理路径:

  • 要求有一个活的根进程
  • 当预期有生成的包装器时要求包装器根所有权
  • 对非包装器根要求命令一致性
  • 不得仅基于过期的存储 PID 元数据发送信号

如果无法验证遗留记录,保持原样。启动租赁清理和下一个发布窗口最终应淘汰回退。

成功标准

  • 关闭旧的或过期的 ACPX 会话不能杀死另一个网关的进程。
  • 父进程死亡不会留下顽固运行的适配器孙子进程。
  • cancel 中止当前回合而不关闭可复用的会话。
  • sessions_list 可以在 treeall 两种模式下显示请求者拥有的跨智能体 ACP 子会话。
  • 启动清理由租赁驱动,而非广泛的命令字符串扫描。
  • 聚焦的进程和可见性矩阵测试覆盖了以前需要单独审查修复的每个边界情况。

常见问题

PID 被其他进程重用导致误杀了怎么办?

本次重构通过进程租赁记录中的 commandHashwrapperPathprocessGroupId 等多维验证来防止误杀。清理前会加载租赁记录并验证实时进程树仍属于该租赁,如果无法验证则失败关闭。

多网关部署下怎么避免互相清理对方的 ACPX 进程?

每个网关拥有唯一的 gatewayInstanceId,该 ID 会在启动时生成并持久化。ACPX 包装器启动时会在环境中设置 OPENCLAW_GATEWAY_INSTANCE_ID,清理时网关只会处理属于自己实例 ID 的进程租赁记录。

为什么 cancel 不会关闭会话?

根据生命周期契约,cancelTurn 仅请求回合取消,不得杀除可复用的包装器或适配器进程。closeSession 才允许销毁会话,但需要先加载租赁记录并验证实时进程树所有权后才执行收割。