从一次误清理事故看 OpenClaw 的 Session 生命周期治理

这篇文章基于 popular open-source AI agent infrastructure / gateway 项目 OpenClaw 的一次真实 Session 清理排障过程。它不是一篇“脚本怎么写”的流水账,而是以 OpenClaw 的工程机制为例,复盘一次从现象、源码、运行数据到自动化治理策略的过程。

OpenClaw GitHub 项目地址:https://github.com/openclaw/openclaw

说明:本文由 OpenClaw 助手根据一次真实配置与排障过程辅助整理,内容经过人工确认。

文中涉及的 session key、用户 ID、群 ID、内部路径和备份目录均已脱敏或泛化。

1. 背景:为什么要清理 Session

OpenClaw 是一个 popular open-source AI agent infrastructure / gateway 项目。作为这类长期运行的 Agent Runtime,它通常会保留大量 session:

  • 用户与 Agent 的主会话,例如 IM 私聊、群聊、Web Chat。
  • Cron 任务会话,用来保存定时任务上下文与运行痕迹。
  • Subagent 会话,用于 Map-Reduce、并行调查、长任务拆分。
  • Heartbeat 会话,用于主动唤醒、系统事件处理、后台完成通知、提醒等。
  • 一些 isolated session,用来降低上下文成本,避免把很长的主会话历史塞进后台任务。注意:这类 session 不一定都是垃圾;有些可能是 OpenClaw CLI 测试 agent、一次性调试 run 或某个可复现测试场景留下的可用上下文。

随着 cron、subagent、heartbeat 被频繁使用,session store 很容易变得臃肿。很多 session 本身是自动生成的、短期有用的;但也有一些 session 承担长期上下文或审计价值,不能简单按名字删除。

这次事故正是从一个看似合理的目标开始的:清理不再需要的 heartbeat sessions。

2. 初始判断:heartbeat session 看起来都像可清理对象

当时我们看到很多 session key 以 :heartbeat 结尾,直觉上它们像是临时 sibling session。

Heartbeat isolated session 的生成逻辑大致是:当配置启用了 isolatedSession 时,heartbeat 不直接复用主 session,而是创建一个轻量的 base:heartbeat sibling session。

这样做有两个直接好处:

  1. 降低 token 成本:heartbeat 不需要每次带上主会话完整历史。
  2. 隔离后台噪声:后台工具输出、系统事件处理,不污染用户主聊天上下文。

从源码看,核心逻辑在 heartbeat runner。

相对路径:src/infra/heartbeat-runner.ts

const useIsolatedSession = heartbeat?.isolatedSession === true;

if (useIsolatedSession) {
  const configuredSession = resolveHeartbeatSession(cfg, agentId, heartbeat);
  const { isolatedSessionKey, isolatedBaseSessionKey } =
    resolveIsolatedHeartbeatSessionKey({
      sessionKey,
      configuredSessionKey: configuredSession.sessionKey,
      sessionEntry: entry,
    });

  const cronSession = resolveCronSession({
    cfg,
    sessionKey: isolatedSessionKey,
    agentId,
    nowMs: startedAt,
    forceNew: true,
  });

  cronSession.sessionEntry.heartbeatIsolatedBaseSessionKey = isolatedBaseSessionKey;
  cronSession.store[isolatedSessionKey] = cronSession.sessionEntry;
  await saveSessionStore(cronSession.storePath, cronSession.store);
}

这段逻辑非常关键:

  • isolated heartbeat 的 key 通常是 baseSessionKey + ':heartbeat'
  • session entry 中会写入 heartbeatIsolatedBaseSessionKey,指回 base session。
  • resolveCronSession(... forceNew: true) 会为 heartbeat 创建新的 isolated session entry。

当时错误的清理条件大概是:

相对路径:projects/session-maintenance/scripts/weekly_session_cleanup.py(示意为事故前的错误分类逻辑)

if key.endswith(':heartbeat') or entry.get('heartbeatIsolatedBaseSessionKey'):
    archive(session)

这个条件能找到 heartbeat,但它不能判断“这个 heartbeat 是否应该被清理”。

3. 事故:把 main heartbeat 也清掉了

实际清理后,active session store 中所有 heartbeat entry 都消失了,其中包括两类不该自动清掉的 session:

  • agent:<agent>:main:heartbeat
  • 另一个 agent 的 agent:<agent>:main:heartbeat

它们虽然从实现上也是 isolated heartbeat sibling,但在系统角色上并不等同于普通聊天 heartbeat sibling。

这就是问题的核心:

实现形态相同,不代表业务角色相同。

从实现看,它们都可能有:

  • :heartbeat 后缀
  • heartbeatIsolatedBaseSessionKey
  • isolated transcript

但从系统角色看,它们至少分成三类:

  1. 主 heartbeat 入口:长期巡检入口,应保留。
  2. channel-specific heartbeat sibling:通常由具体聊天中的 targeted wake、exec completion、提醒事件等生成,很多是低价值临时上下文。
  3. cron-run heartbeat sibling:通常与某次 cron run 后续事件有关,长期价值较低。

把三者混为一谈,就是这次误清理的根因。

4. 为什么没有造成严重后果

这次误清理没有演变成严重数据事故,主要因为执行清理时用了“可恢复归档”,而不是硬删除。

处理方式是:

  • sessions.json 中移除 session entry。
  • 把对应 transcript、trajectory 等文件移动到备份目录。
  • 保留原始 sessions.json 备份。

这意味着恢复主 heartbeat 的成本很低:

  1. 从备份的 session store 中取回目标 key 的 entry。
  2. 将对应 transcript 文件移回 sessions 目录。
  3. 写回当前 sessions.json
  4. 确认当前 active heartbeat 只剩应该保留的 main heartbeat。

最后我们只恢复了两个 main-level heartbeat:

  • agent:<main-agent>:main:heartbeat
  • agent:<secondary-agent>:main:heartbeat

没有恢复聊天 heartbeat sibling,也没有恢复 cron-run heartbeat sibling。

这是一个很重要的工程纪律:

清理默认应该是 archive,而不是 delete。尤其当对象角色和后果不能百分百判断时。

5. 源码层面的关键认识

5.1 Heartbeat 的 prompt 不应该复读旧上下文

OpenClaw 默认 heartbeat prompt 的设计本身很克制。

相对路径:src/auto-reply/heartbeat.ts

export const HEARTBEAT_PROMPT =
  "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";

这说明 heartbeat 的定位不是“把旧聊天重新跑一遍”,而是读取当前 workspace 中的 heartbeat 指令,处理当前到期任务或系统事件。

这也解释了为什么 isolated heartbeat 有价值:它应该是低上下文、任务导向、可重复唤醒的后台机制。

5.2 resolveIsolatedHeartbeatSessionKey 会折叠重复后缀

源码里有一段专门处理 :heartbeat:heartbeat 之类重复 suffix 的逻辑。

它的设计目的不是判断 session 是否有业务价值,而是避免 wake-triggered re-entry 时无限叠加 heartbeat 后缀。

抽象后逻辑类似。

相对路径:src/infra/heartbeat-runner.ts

function resolveIsolatedHeartbeatSessionKey(params) {
  const storedBaseSessionKey = params.sessionEntry?.heartbeatIsolatedBaseSessionKey?.trim();

  if (storedBaseSessionKey && params.sessionKey startsWith storedBaseSessionKey) {
    // collapse repeated :heartbeat suffix
    return {
      isolatedSessionKey: `${storedBaseSessionKey}:heartbeat`,
      isolatedBaseSessionKey: storedBaseSessionKey,
    };
  }

  return {
    isolatedSessionKey: `${params.sessionKey}:heartbeat`,
    isolatedBaseSessionKey: params.sessionKey,
  };
}

这里再一次提醒我们:

  • key pattern 是 routing / normalization 层的实现细节。
  • cleanup policy 不能只依赖 routing 层的实现细节。

5.3 resolveCronSession(... forceNew: true) 是成本隔离工具,不是“可删除”标签

Heartbeat isolated session 复用了 cron isolated session 的创建模式,并传了 forceNew: true

这容易造成一个误解:既然是 force new,它是不是一次性临时对象?

答案是不一定。

forceNew 只说明“本次运行需要新 session / 干净上下文”,不说明“这个 session 没有审计价值,也不说明它可以随便删除”。

尤其是 main heartbeat,它虽然会被重新创建,但旧 session 仍可能包含:

  • 运行轨迹
  • 工具调用记录
  • 最近一次 heartbeat 的状态
  • 排障线索

因此“可再生”不等于“可丢弃”。

6. 修正后的清理模型:三分类,而不是一刀切

事故后,我们把 weekly cleanup 机制改成三分类:

6.1 Auto Archive:确定可归档

自动归档必须同时满足几个条件:

  • 确定是自动生成。
  • 非长期上下文。
  • inactive。
  • 超过阈值。
  • 状态是 done / timeout / failed / unknown 中的安全集合。

典型对象:

  • 已完成的 subagent sessions。
  • cron-run isolated sessions。
  • cron-run heartbeat sibling。
  • channel-specific heartbeat sibling。

但这里要特别小心:temporary / isolated 不等于无价值。比如 OpenClaw CLI 测试 agent、一次性调试 run、复现实验留下的 isolated session,可能仍然有短期审计或复用价值。归档前仍要结合状态、年龄、来源和业务语义判断。

这些对象不限于某个 agent。未来 secondary agent 如果也产生自动生成的 subagent 或 cron-run session,也可以按同一规则处理。

6.2 Preserve:明确保留

明确保留的对象包括:

  • agent main session。
  • agent main heartbeat。
  • Feishu / IM 主聊天 session。
  • cron main session。
  • running / pending / in_progress session。
  • 最近仍在更新的 session。

保留规则的本质是:只要它承担长期上下文、主入口、审计入口、活跃任务,就不自动动它。

6.3 Needs Confirmation:需要人工确认

超过阈值但无法可靠判断的 session,不自动归档,而是列入确认清单。

典型情况:

  • key pattern 不认识。
  • entry 字段异常。
  • 看起来像长期上下文但长时间未更新。
  • 状态不是 done/timeout/failed,也不是 running。
  • 无法判断业务归属。

这些对象会在 weekly cleanup 后给负责人发送私聊确认,而不是直接处理。

7. 实现:一个保守的 cleanup classifier

修正后的 cleanup 脚本核心是 classifier,而不是简单过滤器。

简化版如下。

相对路径:projects/session-maintenance/scripts/weekly_session_cleanup.py

ArchiveClass = Literal['auto_archive', 'preserve', 'confirm']
AUTO_DONE_STATUSES = {'done', 'timeout', 'failed', None}
ACTIVE_STATUSES = {'running', 'in_progress', 'pending'}


def classify_session(key, entry, now, auto_older_days, confirm_older_days):
    if not isinstance(entry, dict):
        return 'confirm', 'non-dict session entry'

    status = entry.get('status')
    days = age_days(entry, now)

    if status in ACTIVE_STATUSES:
        return 'preserve', f'active status={status}'

    if is_agent_main_session(key):
        return 'preserve', 'agent main session'
    if is_main_heartbeat(key):
        return 'preserve', 'agent main heartbeat'
    if is_primary_chat(key):
        return 'preserve', 'primary chat context'
    if is_cron_main_session(key):
        return 'preserve', 'cron main session'

    if is_cron_run_heartbeat(key) and days >= auto_older_days:
        return 'auto_archive', 'cron-run heartbeat older than threshold'

    if is_channel_heartbeat(key) and days >= auto_older_days:
        return 'auto_archive', 'channel heartbeat sibling older than threshold'

    if is_cron_run_session(key):
        if days >= auto_older_days and status in AUTO_DONE_STATUSES:
            return 'auto_archive', 'cron-run isolated session older than threshold'
        if days >= confirm_older_days:
            return 'confirm', 'stale cron-run session but not confidently removable'
        return 'preserve', 'recent cron-run session'

    if is_subagent(key):
        if days >= auto_older_days and status in AUTO_DONE_STATUSES:
            return 'auto_archive', 'subagent older than threshold'
        if days >= confirm_older_days:
            return 'confirm', 'stale subagent but not confidently removable'
        return 'preserve', 'recent subagent'

    if days >= confirm_older_days:
        return 'confirm', 'unknown stale session older than threshold'

    return 'preserve', 'not old or not confidently auto-generated'

这段逻辑有几个有意设计的保守点:

  1. active 状态优先保留。
  2. main / chat / cron main / main heartbeat 优先保留。
  3. 自动归档只针对“确定是自动生成”的 session。
  4. unknown stale 不自动处理,只进入确认清单。
  5. 脚本输出结构化 JSON report,方便 cron 后续判断是否需要私聊确认。

8. Cron 编排:成功静默,疑难上报

weekly cleanup cron 也相应调整为:

  • 每周运行一次。
  • 执行 cleanup 脚本。
  • 如果没有确认项,返回 HEARTBEAT_OK 或静默。
  • 如果有 needsLexConfirmation,给负责人私聊留言。
  • 如果失败,输出 blocker 和错误信息。

这比“定时删除所有过期对象”安全得多。

Cron 的职责不应该是强行替人做不可逆判断,而应该是:

  1. 自动处理低风险对象。
  2. 把高风险、不确定对象整理成可决策清单。
  3. 保留恢复路径。

9. 本次排查和修复用到的命令

下面是本次排查和修复时用到的命令示例,均为脱敏后的相对路径版本。默认在 OpenClaw repo 根目录执行。

9.1 定位 heartbeat / isolated session 相关源码

find . \
  -path './node_modules' -prune -o \
  -path './.git' -prune -o \
  -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.js' \) -print0 \
  | xargs -0 grep -n "HEARTBEAT_PROMPT\|resolveIsolatedHeartbeatSessionKey\|heartbeatIsolatedBaseSessionKey\|forceNew: true"

这条命令主要用于快速确认几个关键实现点分别落在哪些文件:

  • src/auto-reply/heartbeat.ts
  • src/infra/heartbeat-runner.ts
  • src/config/sessions/types.ts
  • src/cron/isolated-agent/session.test.ts

9.2 查看关键源码片段

sed -n '1,90p' src/auto-reply/heartbeat.ts
sed -n '420,470p' src/infra/heartbeat-runner.ts
sed -n '1090,1145p' src/infra/heartbeat-runner.ts

这些片段分别对应:

  • 默认 heartbeat prompt
  • resolveIsolatedHeartbeatSessionKey 的 key 折叠逻辑
  • isolated heartbeat session 创建和 heartbeatIsolatedBaseSessionKey 写入逻辑

9.3 先 dry-run,再执行 cleanup

python3 projects/session-maintenance/scripts/weekly_session_cleanup.py --dry-run --json

确认分类结果符合预期后,再执行真实归档:

python3 projects/session-maintenance/scripts/weekly_session_cleanup.py --json

这里的关键不是命令本身,而是顺序:先 dry-run 生成分类报告,再执行可恢复归档。对于 unknown / stale / temporary isolated session,默认进入确认清单,而不是直接归档。

9.4 只检查分类逻辑,不移动文件

如果只想验证不同阈值下的分类效果,可以调整阈值:

python3 projects/session-maintenance/scripts/weekly_session_cleanup.py \
  --dry-run \
  --json \
  --auto-older-days 7 \
  --confirm-older-days 30

这类命令适合用来观察 cleanup policy 是否过于激进,尤其是是否会误伤 main heartbeat、主聊天 session、CLI 测试 agent 或一次性调试 run 留下的 isolated session。

10. 最终得到的工程原则

这次事故留下了几个非常实用的原则。

原则一:不要把实现标记当成业务语义

key.endswith(':heartbeat')heartbeatIsolatedBaseSessionKey 只能说明对象如何生成,不能说明对象能不能删。

实现标记只能作为候选发现手段,不能作为最终决策依据。

原则二:可再生不等于可丢弃

main heartbeat 可以重新生成,但它的历史仍可能有排障价值。

自动化系统里很多对象都“可再生”,但它们在某个时间窗口内可能承担审计、回滚、解释系统行为的作用。

原则三:清理脚本应该是 classifier,而不是 grep

安全 cleanup 的第一步不是 delete where pattern matches,而是:

  • preserve
  • auto_archive
  • needs_confirmation

只有进入 auto_archive 的对象才应该被脚本处理。

原则四:默认 archive,不默认 delete

这次能快速恢复,是因为清理默认走归档:

  • store 有备份。
  • transcript 文件被移动而不是删除。
  • 恢复只需要合并 entry 和移回文件。

对于 AI Agent 这类长上下文系统,硬删应该是最后选项。

原则五:自动化要有“求确认”的出口

不确定对象不应该让脚本猜。

更好的方式是每周整理一份确认清单,私聊发给负责人,让人做最后判断。

这不是降低自动化程度,而是把自动化用在更合适的位置:减少人工整理成本,而不是替人承担不可逆决策。

11. 小结

这次 session 清理事故本身不大,但它暴露了一个常见问题:我们太容易把“看起来像临时对象”的东西当成“可以清理的对象”。

在 OpenClaw 这样的 Agent Runtime 里,session 不只是 transcript 文件;它可能同时承担上下文、路由、审计、任务状态、后台事件消费等多个角色。

因此,一个成熟的 session maintenance 机制应该满足:

  • 有明确 preserve 规则。
  • 有保守的 auto-archive 规则。
  • 有 needs-confirmation 分支。
  • 默认可恢复归档。
  • 输出结构化报告。
  • 定期运行,但不替人做高风险判断。

最终我们得到的不是一个更激进的清理脚本,而是一个更可靠的治理机制。

这也是我对 AI Agent 基础设施越来越强烈的感受:

真正可靠的自动化,不是“永远替你做决定”,而是知道哪些决定自己不该做。

posted @ 2026-05-05 03:14  LexLuc  阅读(18)  评论(0)    收藏  举报