深度剖析 senpi-task 内存驻留回收:从 idle 驱逐、有界默认值到 eviction/send 竞态仲裁

发布时间:2026/9/21 1:39:11
深度剖析 senpi-task 内存驻留回收:从 idle 驱逐、有界默认值到 eviction/send 竞态仲裁 人工智能AI Agent代码智能体多智能体MCP ClientsAgent 编排【免费下载链接】oh-my-openagentOmO: Just type mass ulw keyword with your prompt. Now you are the master of graph engineering.项目地址https://gitcode.com/gh_mirrors/oh/oh-my-openagent点击查看免费下载导读本文基于 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 而非 423.5在途发送被误判steering 引擎追踪 in-flight steer/revive 操作与持久化pending_steering一起暴露给驱逐检查核心机制一闲置驻留回收Idle Resident Reclamation15 分钟闲置窗口的设计取舍修复的核心决策是闲置 15 分钟后驱逐 terminal resident这个窗口刻意短于既有 24 小时记录 TTLttl_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.tsconst 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 完成后会调用cleanupExpiredRecordsTTL 过期记录清理返回的 disposer 在session_shutdown时调用——也就是说进程退出前会先完成一次过期记录清理。unref 计时器timer.unref?.()保证这个 15 分钟 sweep 不会阻塞进程退出属于后台非阻塞任务。TTL 清理的保留规则与 idle 回收的边界cleanupExpiredRecordspackages/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锁内重读看到新认领记录保留——扫描后直接删的实现会在该用例上 FAILsweep 中途崩溃留下墓碑下次 sweep 幂等地补齐删除不会抛错也不会复活记录。核心机制二有界驻留容量默认值Bounded Residency Cap问题parallelism 14 时为何会算出 42修复前驻留容量上限存在一个无界放大的问题当机器 parallelism可用并行度较高时按倍数推导出的默认 cap 会超出合理范围。修复分支将默认值收紧为min(16, max(8, parallelism * 2))parallelism 14 时max(8, 28) 28min(16, 28) 16而不是 422 核机器max(8, 4) 88 核以内都保底 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 中的默认值为 8residency_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.tsresolveOmoTaskSettings({}, () 14).residency_max_children断言为 16。unlimited 与 0 的语义ResidencyMaxChildrenInputSchema接受非负整数或字面量unlimitedpackages/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 驱逐达到容量上限时的准入逻辑在admitResidentpackages/senpi-task/src/lifecycle/residency.ts容量按父会话作用域统计parent_session_idresidency_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.5pendingSends 从 Set 升级为 Map修复 3.5 的核心是把 in-flight steer/revive 从有没有升级为有几个。在 packages/senpi-task/src/steering/engine.tsconst pendingSends new Mapstring, 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从Mapstring, number改回旧的Setstring推送后跑远程序列化作用域结果为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_continuablepackages/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 节奏修复分支严格遵循 TDDREDe7768de54首个测试提交在实现之前加入回归覆盖——idle 测试捕获修复前行为terminal resident 仍为resident且未 dispose、配置测试捕获 parallelism-14 时旧的 42、adapter 测试要求排队消息hasPendingSends true而 bridge 当时返回硬编码 falseGREEN3fa909e17实现提交把 idle 测试改为断言驱逐/dispose、配置断言改为 16bundle 检查对重新生成的产物通过。远程 macOS 验证初始的 macOS harness 尝试因/tmp/omo-mac-test2.mjs缺失受阻harness 重建后规定命令完整通过MAC_TEST_TIMEOUT_S2400 bun /tmp/omo-mac-test2.mjs fix/senpi-mem-task-residency -- packages/senpi-task结果252 个文件中 1773 passed、1 skipped、0 failedexit 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 minutesscheduler 断言钉死 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正整数90000015 分钟闲置驻留回收窗口0 非法residency_max_children非负整数 /unlimited动态min(16, max(8, parallelism * 2))每父会话驻留上限0与unlimited均表示无界ttl_ms正整数8640000024 小时终态记录及其产物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 ClientsAgent 编排【免费下载链接】oh-my-openagentOmO: Just type mass ulw keyword with your prompt. Now you are the master of graph engineering.项目地址https://gitcode.com/gh_mirrors/oh/oh-my-openagent点击查看免费下载相关推荐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),仅供参考