从一次误清理事故看 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。
这样做有两个直接好处:
- 降低 token 成本:heartbeat 不需要每次带上主会话完整历史。
- 隔离后台噪声:后台工具输出、系统事件处理,不污染用户主聊天上下文。
从源码看,核心逻辑在 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
但从系统角色看,它们至少分成三类:
- 主 heartbeat 入口:长期巡检入口,应保留。
- channel-specific heartbeat sibling:通常由具体聊天中的 targeted wake、exec completion、提醒事件等生成,很多是低价值临时上下文。
- cron-run heartbeat sibling:通常与某次 cron run 后续事件有关,长期价值较低。
把三者混为一谈,就是这次误清理的根因。
4. 为什么没有造成严重后果
这次误清理没有演变成严重数据事故,主要因为执行清理时用了“可恢复归档”,而不是硬删除。
处理方式是:
- 从
sessions.json中移除 session entry。 - 把对应 transcript、trajectory 等文件移动到备份目录。
- 保留原始
sessions.json备份。
这意味着恢复主 heartbeat 的成本很低:
- 从备份的 session store 中取回目标 key 的 entry。
- 将对应 transcript 文件移回 sessions 目录。
- 写回当前
sessions.json。 - 确认当前 active heartbeat 只剩应该保留的 main heartbeat。
最后我们只恢复了两个 main-level heartbeat:
agent:<main-agent>:main:heartbeatagent:<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'
这段逻辑有几个有意设计的保守点:
- active 状态优先保留。
- main / chat / cron main / main heartbeat 优先保留。
- 自动归档只针对“确定是自动生成”的 session。
- unknown stale 不自动处理,只进入确认清单。
- 脚本输出结构化 JSON report,方便 cron 后续判断是否需要私聊确认。
8. Cron 编排:成功静默,疑难上报
weekly cleanup cron 也相应调整为:
- 每周运行一次。
- 执行 cleanup 脚本。
- 如果没有确认项,返回
HEARTBEAT_OK或静默。 - 如果有
needsLexConfirmation,给负责人私聊留言。 - 如果失败,输出 blocker 和错误信息。
这比“定时删除所有过期对象”安全得多。
Cron 的职责不应该是强行替人做不可逆判断,而应该是:
- 自动处理低风险对象。
- 把高风险、不确定对象整理成可决策清单。
- 保留恢复路径。
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.tssrc/infra/heartbeat-runner.tssrc/config/sessions/types.tssrc/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 基础设施越来越强烈的感受:
真正可靠的自动化,不是“永远替你做决定”,而是知道哪些决定自己不该做。

浙公网安备 33010602011771号