- 人工智能
- AI Agent
- 代码智能体
- 多智能体
- MCP Clients
- Agent 编排
【免费下载链接】oh-my-openagent
OmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.
导读
本文基于 oh-my-openagent 仓库中 senpi-task 子系统的内存驻留(residency)回收修复证据报告,完整讲解三个核心技术点:闲置驻留回收(idle resident reclamation)、有界驻留容量默认值(bounded residency cap)与驱逐与发送之间的竞态仲裁(eviction/send race arbitration)。senpi-task 是 OmO 图工程编排中负责把子任务 Agent 会话持久化、暂停与恢复的模块,本文会带你从配置项、底层实现到 TDD 回归测试,理解如何在不丢失任务输出、不产生孤儿进程的前提下,把驻留在进程内的大量AgentSession安全释放。读完你既能独立配置residency_max_children与resident_idle_timeout_ms,也能复现并理解整个修复分支的 RED/GREEN 验证闭环。
背景:为什么需要内存驻留回收
在 senpi-task 的模型里,"驻留(resident)"指已被 spawn 但尚未 dispose 的父子会话关系:子任务完成后,其AgentSession仍以 resident 状态留在进程内,以便task_output等通道继续读取、steer/revive 可以随时续接。这种设计的代价是内存:一个完整在进程内的 AgentSession 可能非常庞大,若长期不回收,长时间运行的主机会累积大量驻留会话,内存压力不可控。
修复分支fix/senpi-mem-task-residency(基于origin/dev的d50518d45,包含提交e7768de54、3fa909e17、cd157168d)针对三个核心问题:
| 编号 | 问题 | 修复方案 |
|---|---|---|
| 3.1 | 闲置驻留无回收 | 空闲超过 15 分钟且updated_at未被触碰的 terminal resident 被驱逐,同时保留持久化记录供task_output读取 |
| 3.2 | 默认容量无界 | 默认 residency 上限改为min(16, max(8, parallelism * 2)),parallelism 14 时取 16 而非 42 |
| 3.5 | 在途发送被误判 | steering 引擎追踪 in-flight steer/revive 操作,与持久化pending_steering一起暴露给驱逐检查 |
核心机制一:闲置驻留回收(Idle Resident Reclamation)
15 分钟闲置窗口的设计取舍
修复的核心决策是:闲置 15 分钟后驱逐 terminal resident,这个窗口刻意短于既有 24 小时记录 TTL(ttl_ms默认86400000毫秒)。这样设计的好处是:
- 大块内存(in-process 的
AgentSession)被尽快释放; - 持久化记录仍然保留,
task_output依旧可以读到终态输出; - 驱逐不等于销毁记录,语义上只是"从内存驻留退回持久化"。
在源码中,闲置窗口由配置项resident_idle_timeout_ms控制,默认值900000毫秒(15 分钟),且必须是正整数——设置为 0 会被 schema 直接拒绝:
// packages/omo-config-core/src/schema/task.ts resident_idle_timeout_ms: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).default(900000),对应的 schema 测试确认了"缺省即 15 分钟、显式配置可覆盖、0 必须抛错"三条契约:
// packages/omo-config-core/src/schema/task.ts 配套测试 expect(resolveOmoTaskSettings({})).toHaveProperty("resident_idle_timeout_ms", 900000) expect(resolveOmoTaskSettings({ resident_idle_timeout_ms: 37 })).toHaveProperty("resident_idle_timeout_ms", 37) expect(() => resolveOmoTaskSettings({ resident_idle_timeout_ms: 0 })).toThrow()reclaimIdleResidents 的实现细节
核心函数位于 packages/senpi-task/src/lifecycle/residency.ts。候选筛选条件(全部满足才可回收):
record.residency_state === "resident" && (record.host_pid === context.hostPid || context.registry.get(record.task_id) !== undefined) && TERMINAL_STATUSES.has(record.status) && Date.parse(record.updated_at) <= cutoff && !context.registry.hasPendingSends(record.task_id)逐条解读:
- 归属校验:只回收"本进程所有"或"本进程持有活 handle"的驻留记录。
host_pid === context.hostPid是本地所有权证明;registry.get(task_id)存在说明本进程内存里确实有该 handle。 - 终态校验:只有 terminal 状态(
completed、error、cancelled、lost等)才可回收。仍在 running/pending 的子任务绝不能被闲置回收误杀。 - 时间校验:
updated_at早于 cutoff。注意updated_at在每次 steer/revive 时都会被刷新,因此它天然是"最近使用时间"的度量。 - 在途发送校验:
hasPendingSends为 false 才回收——这条正是修复 3.5 的落点,后面会展开。
回收动作本身分两条路径:
if (fresh.killed === true || fresh.status === "cancelled" || fresh.status === "lost") { await destroyResidentTask(context, fresh.task_id, "cancel") } else { const handle = context.registry.get(fresh.task_id) if (handle === undefined) continue // 缺失 handle 由 reconciliation 负责,失败的 dispose 不算成功的 park await suspendHandle(context, handle, "idle") }- 已被 kill、cancelled 或 lost 的驻留:直接
destroyResidentTask销毁; - 正常终态驻留:通过
suspendHandle(handle, "idle")暂停(park)到持久化状态。
且每次回收前都会重新读记录做二次校验(context.store.load),并且先经过tryClaimEviction抢占驱逐权(见后文竞态仲裁),finally中释放。整个过程中出现的异常会被记录为senpi-task idle resident suspension failed,带上 taskId 与错误信息,不会让 sweep 崩溃。
周期调度与 session_shutdown 联动
调度器由startIdleResidentReclaimer实现(packages/senpi-task/src/lifecycle/residency.ts):
const timer = context.idleReclaimerScheduler.setInterval(() => { if (running) return running = true void reclaimIdleResidents(context) .then(() => cleanupExpired()) .catch((error) => { log("senpi-task idle resident sweep failed", { error: String(error) }) }) .finally(() => { running = false }) }, context.config.resident_idle_timeout_ms) timer.unref?.()要点有三:
- 周期即超时窗口:以
resident_idle_timeout_ms为周期;running标志防止上一次 sweep 尚未完成时重入。 - 级联 TTL 清理:每次 idle sweep 完成后,会调用
cleanupExpiredRecords(TTL 过期记录清理),返回的 disposer 在session_shutdown时调用——也就是说,进程退出前会先完成一次过期记录清理。 - unref 计时器:
timer.unref?.()保证这个 15 分钟 sweep 不会阻塞进程退出,属于后台非阻塞任务。
TTL 清理的保留规则(与 idle 回收的边界)
cleanupExpiredRecords(packages/senpi-task/src/lifecycle/ttl.ts)负责删除超过ttl_ms的终态记录及其产物,但它有多层"不可删除"保护,与 idle 回收形成清晰的职责边界:
| 场景 | 是否删除 | 原因 |
|---|---|---|
| 非终态记录(running/pending/interrupted) | 永不删除 | 暂停中的子任务工作仍在途,删了会丢工作 |
| 本进程 registry 中有活 handle | 保留 | 删了会孤儿化内存 handle,且迟到的 transcript 追加会重建已删日志 |
| 被存活 host 进程认领的 resident 记录 | 保留 | 那是别的进程可 revive 的句柄,不能从脚下抹掉 |
终态但通知未投递(notify_on_terminal或 legacynotification_failed_epoch) | 保留 | 交给未通知完成 reconciler 恢复 |
lost且进程模式但 pid 未证死 | 保留 | breadcrumbs 可能还需要;必须有 pid-dead 证明 |
TTL 删除是两阶段原子墓碑:阶段一在记录锁内tombstoneIfExpired重读+重校验+改名为<taskId>.json.expunging,锁内重读关闭了"扫描后删除"的认领竞态;阶段二在锁外删除 children 目录、spill、日志再丢墓碑。每次 sweep 会先完成上一次崩溃 sweep 遗留的墓碑(幂等、无锁),实现"no-orphan law"——孤儿进程必须先于产物删除前销毁(host session 直接关闭,lostRPC 孤儿则 SIGTERM→SIGKILL 升级,全部发生在产物删除之前)。
测试佐证:resident 记录保护与跨进程所有权
packages/senpi-task/src/lifecycle/ttl.test.ts 用一组用例钉死了这些边界:
- 过期终态记录 + 活 handle:保留;handle 被 forget 后再 sweep 才删除;
- 过期 resident 记录 + 存活的外部进程(
host_pid: 4242存活):保留;外部 owner 死后才删除; - 扫描与删除之间被 revive 认领:
tombstoneIfExpired锁内重读看到新认领,记录保留——扫描后直接删的实现会在该用例上 FAIL; - sweep 中途崩溃留下墓碑:下次 sweep 幂等地补齐删除,不会抛错也不会复活记录。
核心机制二:有界驻留容量默认值(Bounded Residency Cap)
问题:parallelism 14 时为何会算出 42
修复前,驻留容量上限存在一个无界放大的问题:当机器 parallelism(可用并行度)较高时,按倍数推导出的默认 cap 会超出合理范围。修复分支将默认值收紧为:
min(16, max(8, parallelism * 2))- parallelism = 14 时,
max(8, 28) = 28,min(16, 28) = 16,而不是 42; - 2 核机器:
max(8, 4) = 8,8 核以内都保底 8; - 高核机器最多 16,防止一台 14 核机器悄悄为每个父会话保留 42 个完整
AgentSession。
这个默认值在 packages/omo-config-core/src/schema/task.ts 中有明确注释:8 是低端基线,每 worker 两个子任务足够并行余量,16 的上限阻止高核主机失控:
const DEFAULT_RESIDENCY_MAX_CHILDREN = 16 // ... residency_max_children: record["residency_max_children"] ?? Math.min(DEFAULT_RESIDENCY_MAX_CHILDREN, Math.max(8, resolveParallelism() * 2)),schema 中的默认值为 8(residency_max_children: ResidencyMaxChildrenInputSchema.default(8)),而resolveOmoTaskSettings在未显式配置时用上述公式动态计算;global_concurrency同理保底max(8, parallelism * 2)。相关测试见 packages/senpi-task/src/config/settings.test.ts 与 packages/omo-config-core/src/schema/task.test.ts:resolveOmoTaskSettings({}, () => 14).residency_max_children断言为 16。
"unlimited" 与 0 的语义
ResidencyMaxChildrenInputSchema接受非负整数或字面量"unlimited"(packages/omo-config-core/src/schema/task.ts)。在引擎侧(packages/senpi-task/src/lifecycle/residency.ts):
// Both the "unlimited" literal and a 0 cap mean unbounded residency (omo.json accepts either). function isUnbounded(maxChildren: number | "unlimited"): maxChildren is "unlimited" | 0 { return maxChildren === "unlimited" || maxChildren === 0 }即"unlimited"与0都表示无界驻留——放行所有子任务、永不驱逐。task.test.ts对residency_max_children: 0的解析与直通也有专门用例。
容量门控:admitResident 与 LRU 驱逐
达到容量上限时的准入逻辑在admitResident(packages/senpi-task/src/lifecycle/residency.ts):
- 容量按父会话作用域统计(
parent_session_id+residency_state === "resident"),与持久化契约保持兼容; - 未达上限或无界:直接
admitted; - 已达上限:用
lruEvictable找最老的、terminal 且无在途发送的驻留作为 victim,通过destroyResidentTask(..., "evict")驱逐后准入; - 无 victim 可驱逐:拒绝并返回
AgentLimitReached,错误里带上 resident 列表(task_id/name/status),调用方可以据此向用户解释原因。
lruEvictable的注释明确:每个终态状态(包括 lost 与 cancelled)都可回收——lost 子任务已不可达,绝不能占着名额。
报告还明确记录了一个 follow-up:当前容量按进程内 registry 逐会话组合,尚不存在跨进程共享的 host 级 residency registry seam,因此 per-process 计数没有改变,host 级进程级驻留注册表/上限是明确记录的后续工作(见原文 "Follow-ups" 一节)。
核心机制三:在途发送追踪与驱逐竞态仲裁
修复 3.5:pendingSends 从 Set 升级为 Map
修复 3.5 的核心是把 in-flight steer/revive 从"有没有"升级为"有几个"。在 packages/senpi-task/src/steering/engine.ts:
const pendingSends = new Map<string, number>()发送开始时计数 +1,结束后递减(count <= 1才删除 key,否则保留递减后的计数),从而叠加的多次发送中,第一个完成不得清除 pending 状态:
pendingSends.set(taskId, (pendingSends.get(taskId) ?? 0) + 1) // ... send completes: const count = pendingSends.get(taskId) ?? 0 if (count <= 1) pendingSends.delete(taskId) else pendingSends.set(taskId, count - 1)hasPendingSends将在途计数与持久化pending_steering队列合并判断:
function hasPendingSends(taskId: string): boolean { return (pendingSends.get(taskId) ?? 0) > 0 || (tryLoad(taskId)?.pending_steering?.length ?? 0) > 0 }manager 与 omo-senpi 的 residency bridge 正是用这个状态做驱逐检查——排队消息存在时,记录不能被 idle 回收或 LRU 驱逐。
Mutation-proof 验证:为什么必须是计数而不是集合
报告记录了完整的 mutation 对照实验:
- Mutation RED(提交
3507fb6b4):临时把pendingSends从Map<string, number>改回旧的Set<string>,推送后跑远程序列化作用域,结果为1781 pass、1 skip、2 fail——两条失败正是 in-process 与 rpc 两条路径的"send #1 结束后期望 pending 仍为 true,实际拿到 false"。 - Restore GREEN(提交
bc722b447):恢复到计数器实现后重跑,1783 pass、1 skip、0 fail。
修正后的测试使用独立的完成门(completion gates):先 await send #1 完成,断言 pending 状态仍为 true,再放行并 await send #2,断言其为 false。全程无 sleep、无真实定时器,完全确定性。
eviction/send 竞态:同步认领 + 类型化拒绝
进一步的竞态修复(提交06d3a81f5RED、92f53583a实现、52843c1d7重定基后的 bundle 再生成,当前 dev 已包含 PR #7533)解决的是"驱逐与发送并发"的问题:
- 驱逐在 teardown await 之前先获取同步的 per-task 认领(
tryClaimEviction,见 packages/senpi-task/src/lifecycle/residency.ts); - 发送/revive获取同一个 task 仲裁(
hasPendingSends/ 发送路径),当驱逐已持有所有权时,收到类型化拒绝not_continuable(packages/senpi-task/src/steering/engine.ts 附近,reason 由notContinuableReason(record)推导,并附带TASK_OUTPUT_SUGGESTION建议); - in-flight 发送使用 per-task 计数;
- sweep 失败时记录 task id 与错误。
报告还描述了关键时序细节:如果记录已killed === true或 status 为cancelled/lost,驱逐走destroyResidentTask(..., "cancel");正常终态则suspendHandle(handle, "idle")。not_continuable的判定在"answers 可从 transcript 重开并重新投递"的情况下不会出现——只有真正无法续接时才拒绝。
确定性回归用例
该竞态修复配套了三条确定性 RED 回归(提交06d3a81f5):
- 驱逐已认领时,在最后一次 idle 观察之后才开始的 steer;
- 在已认领 teardown 期间到达的 revive;
- 叠加发送:第一个完成不得在第二个仍活跃时清除 pending 状态。
GREEN 提交92f53583a实现 per-task 互斥认领、类型化拒绝、计数式 pending sends 与 sweep 失败日志;后续 bundle 提交fd1aa8f54在重定基到当前origin/dev后生成。
TDD 证据与验证闭环
RED→GREEN 节奏
修复分支严格遵循 TDD:
- RED(
e7768de54):首个测试提交在实现之前加入回归覆盖——idle 测试捕获修复前行为(terminal resident 仍为resident且未 dispose)、配置测试捕获 parallelism-14 时旧的 42、adapter 测试要求排队消息hasPendingSends === true而 bridge 当时返回硬编码 false; - GREEN(
3fa909e17):实现提交把 idle 测试改为断言驱逐/dispose、配置断言改为 16;bundle 检查对重新生成的产物通过。
远程 macOS 验证
初始的 macOS harness 尝试因/tmp/omo-mac-test2.mjs缺失受阻;harness 重建后,规定命令完整通过:
MAC_TEST_TIMEOUT_S=2400 bun /tmp/omo-mac-test2.mjs fix/senpi-mem-task-residency -- packages/senpi-task结果:252 个文件中 1773 passed、1 skipped、0 failed,exit 0(按任务范围约束,未运行本地 Bun 测试)。
Bundle 验证与后续 CI 修正
bun install使用 Bun 1.4.0 完成;node packages/omo-senpi/plugin/scripts/build-extension.mjs --check在 bundle 再生成后通过,跟踪产物omo.js、omo-task.js随提交cd157168d纳入;- 首次 Ubuntu 全量套件暴露两个分支引发的问题:idle 测试标题仍用 RED 时代措辞,且其假时钟
960,000早于 fixture 时间戳1,000,000,导致记录被正确保留(测试误以为保留是失败)。修正后测试改用契约标题、注入式 scheduler(无真实定时器)、注入时钟1,000,000 + 16 minutes,scheduler 断言钉死 15 分钟间隔; - 在重定基的
origin/dev(含 PR #7533 的omo.js)之上再生成两个 plugin bundle 后,build-extension.mjs --check通过; - 替换后的 PR CI 全绿:Ubuntu test shards 1/2 与 2/2、macOS 与 Windows test shards、typecheck、build,以及 Ubuntu/macOS/Windows 三平台的 Senpi 兼容性。
最终序列化验证:SHAfd1aa8f54与52843c1d7两处重跑均为1783 passed、1 skipped、0 failed。
配置速查与实战建议
在 omo.json 的 task 配置块中,与本主题相关的可配置项如下:
| 配置键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
resident_idle_timeout_ms | 正整数 | 900000(15 分钟) | 闲置驻留回收窗口;0 非法 |
residency_max_children | 非负整数 /"unlimited" | 动态min(16, max(8, parallelism * 2)) | 每父会话驻留上限;0与"unlimited"均表示无界 |
ttl_ms | 正整数 | 86400000(24 小时) | 终态记录及其产物(children 目录、spill、日志)的保留时长 |
实战建议:
- 默认即可:15 分钟 idle 回收 + 16 上限的高核保护已经在默认配置下生效,无需额外设置;
- 内存敏感场景:可下调
resident_idle_timeout_ms(例如300000即 5 分钟)加速释放AgentSession,但要注意task_output依旧可用(记录保留到 TTL),只是 revive 时需要重建 handle; - 需要长驻可续接:上调
residency_max_children或设"unlimited",但要清楚无界意味着永不自动驱逐; - 不要动
ttl_ms来"回收内存":TTL 删的是磁盘上的终态记录与产物,与内存驻留回收是两条独立机制;idle 回收先把内存 handle 释放掉,记录仍可被task_output读取,这才是内存与可用性的正确平衡点。
已知限制与明确 follow-up
报告在 "Explicit follow-ups" 一节如实记录了未完成事项,均以当前仓库为准:
- Matrix/Lane 3.3:接入 DAG 剪枝与缓存 run 列表;
- Matrix/Lane 3.4:减少 snapshot fan-out 的序列化抖动;
- Matrix/Lane 3.6:清扫孤儿 omo-family 进程;
- Suspect #4:移除或封顶 curated-agent 的进程内 pinning;
- 引入host 所有、进程级共享的 residency registry/上限,覆盖多会话 host(当前 per-process 计数、按会话组合的现状已作为 follow-up 记录在案)。
结语
senpi-task 的内存驻留回收不是一个"加个定时器删东西"的简单需求,而是围绕所有权(host_pid / registry handle)、终态与未通知完成投递、在途发送三个约束建立的严谨状态机:idle 回收负责快速释放内存 handle 而保留持久化记录,有界默认值阻止高核主机上的驻留失控,per-task 认领与类型化not_continuable拒绝则保证了驱逐与发送在任何交错下都不会产生孤儿进程或丢失消息。这一切都被 packages/senpi-task/src/lifecycle/residency.ts、packages/senpi-task/src/lifecycle/ttl.ts、packages/senpi-task/src/steering/engine.ts 以及ttl.test.ts、residency-regression.test.ts、settings.test.ts等测试完整钉死,是一份值得对照研读的并发内存管理范本。
- 人工智能
- AI Agent
- 代码智能体
- 多智能体
- MCP Clients
- Agent 编排
【免费下载链接】oh-my-openagent
OmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.
相关推荐
Apache Ignite内存管理:深入理解驱逐策略(Eviction Policies)
Apache Ignite内存管理:深入理解驱逐策略 Eviction Policies 引言:为什么需要驱逐策略? 在分布式内存计算领域,内存资源是宝贵且有限
数据库分布式数据库后端Self-RAG Llama2 7B输入格式指南:掌握指令模板与段落标记的正确用法
Self RAG Llama2 7B输入格式指南:掌握指令模板与段落标记的正确用法 Self RAG Llama2 7B是一款强大的AI模型,掌握其输入格式是充
MicroPython QSTR 字符串驻留机制深度解析:从 ROM 静态池到运行时动态驻留
MicroPython QSTR 字符串驻留机制深度解析:从 ROM 静态池到运行时动态驻留 MicroPython 面向微控制器与资源受限系统,内存(RAM
嵌入式语言运行时编程语言解释器编译器物联网系统编程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考