news 2026/9/21 1:38:51

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度剖析 senpi-task 内存驻留回收:从 idle 驱逐、有界默认值到 eviction/send 竞态仲裁
  • 人工智能
  • 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.

项目地址: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_childrenresident_idle_timeout_ms,也能复现并理解整个修复分支的 RED/GREEN 验证闭环。

背景:为什么需要内存驻留回收

在 senpi-task 的模型里,"驻留(resident)"指已被 spawn 但尚未 dispose 的父子会话关系:子任务完成后,其AgentSession仍以 resident 状态留在进程内,以便task_output等通道继续读取、steer/revive 可以随时续接。这种设计的代价是内存:一个完整在进程内的 AgentSession 可能非常庞大,若长期不回收,长时间运行的主机会累积大量驻留会话,内存压力不可控。

修复分支fix/senpi-mem-task-residency(基于origin/devd50518d45,包含提交e7768de543fa909e17cd157168d)针对三个核心问题:

编号问题修复方案
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)

逐条解读:

  1. 归属校验:只回收"本进程所有"或"本进程持有活 handle"的驻留记录。host_pid === context.hostPid是本地所有权证明;registry.get(task_id)存在说明本进程内存里确实有该 handle。
  2. 终态校验:只有 terminal 状态(completederrorcancelledlost等)才可回收。仍在 running/pending 的子任务绝不能被闲置回收误杀。
  3. 时间校验updated_at早于 cutoff。注意updated_at在每次 steer/revive 时都会被刷新,因此它天然是"最近使用时间"的度量。
  4. 在途发送校验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) = 28min(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.tsresidency_max_children: 0的解析与直通也有专门用例。

容量门控:admitResident 与 LRU 驱逐

达到容量上限时的准入逻辑在admitResident(packages/senpi-task/src/lifecycle/residency.ts):

  1. 容量按父会话作用域统计(parent_session_id+residency_state === "resident"),与持久化契约保持兼容;
  2. 未达上限或无界:直接admitted
  3. 已达上限:用lruEvictable最老的、terminal 且无在途发送的驻留作为 victim,通过destroyResidentTask(..., "evict")驱逐后准入;
  4. 无 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):临时把pendingSendsMap<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):

  1. 驱逐已认领时,在最后一次 idle 观察之后才开始的 steer
  2. 在已认领 teardown 期间到达的 revive
  3. 叠加发送:第一个完成不得在第二个仍活跃时清除 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.jsomo-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 兼容性。

最终序列化验证:SHAfd1aa8f5452843c1d7两处重跑均为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.tsresidency-regression.test.tssettings.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.

项目地址:https://gitcode.com/gh_mirrors/oh/oh-my-openagent
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 1:36:56

Java Swing+MySQL实战:儿童疫苗接种与体检管理系统全解析

简介&#xff1a;一套基于Java的疫苗接种与儿童体检系统设计实现完整项目文档&#xff0c;面向具备Java基础的后端工程师、医疗信息化开发者及智慧健康研究人员&#xff0c;用于解决传统手工记录效率低、信息易错漏等公共健康管理痛点。文档从项目背景、目标与意义入手&#xf…

作者头像 李华
网站建设 2026/9/21 1:36:45

CMMB标准LDPC译码器FPGA实现:准循环矩阵驱动的硬件优化架构

简介&#xff1a;本资源是一套面向FPGA开发与通信算法研究者的LDPC译码器完整实现方案&#xff0c;聚焦CMMB标准下的高性能低复杂度译码需求&#xff0c;适用于数字通信、信道编码课程设计及FPGA工程实践。内容涵盖MATLAB 2013b仿真模型、ISE 12.1与Quartus II 10.0双平台Veril…

作者头像 李华
网站建设 2026/9/21 1:32:41

两小时搭建AI Agent实战:从零到跑通最小闭环

周末下午本来只想给手头几个零散的脚本加个统一入口&#xff0c;结果一不留神就花了两小时顺手搭了个 AI Agent。整个过程不算复杂&#xff0c;但踩了几个坑&#xff0c;也把很多一直模糊的概念彻底理清了。这篇文章就把我这两小时的完整经历写下来&#xff0c;包括从零动手的步…

作者头像 李华