Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md
T
2026-07-30 22:00:34 +08:00

11 KiB
Raw Blame History

Agent Note: 使用单一持久锁实现排队手动压缩

Status: implemented

English | 中文

问题

自动压缩(compaction)可以保护上下文窗口,但交互用户还需要一种确定性方法,在压力策略触发前压缩累积的历史。把 /compact 作为提示词文本发送会消耗一个模型轮次,还会让会话模型重新解释一项直接控制操作。在某个 UI 内实现该功能,则会重复命令发现、生命周期日志记录、取消与后端策略。

面向用户的命令在轮次之间到达,并且必须异步生成摘要。在等待期间获接纳的提示词必须保留普通身份、FIFO 位置与唤醒行为,但不得从即将被压缩替换的历史派生请求。仅检查状态并不足够:唤醒发送会把驱动器的认领安排为 microtask,因此在同一 tick 内存在一段间隔,此时状态仍显示 idle,但提示词已经拥有优先权。

手动、压力、溢出和显式范围入口点还需要共享同一项互斥事实。仅使用进程本地标志无法解释一份崩溃恢复后的日志,而先摘要再记录的事务在开销较大的等待期间不会留下持久证据。反过来,把标记对视为排他容器又会禁止有效的空闲注入,尽管注入按定义不会唤醒,并且会在轮次之间立即执行。

本 Agent Note 扩展压缩能力 seam、会话 end-seed 边界和移除纯日志事件的合成轮次。取代关系审计只发现部分重叠:三者均保持活动状态,并拥有各自更广泛的决策。

决策

/compact 是基于后端无关 seam 的命令

@deepseek-ai/dsh-command-compact 通过 ctx.commands 注册一个无参数、面向用户的命令。它调用第三个抽象 CompactService 操作 compactNow(agent, signal),并把封闭的 ManualCompactionError 分类体系(busy | changed | summary | commit | persistence)映射为直接 UI 结果。command/run 和 command/done 保留命令生命周期,同时不进入模型历史,也不消耗模型循环轮次。

命令插件会独立跟踪每个实际处理器 promise,不依赖命令执行器的中止感知等待。其复合生命周期 effect 先注销 /compact,再异步等待所有已开始的处理器结算,因此根级 teardown 只有在后端的闭合与 flush 工作结算后才会完全停稳。

该 seam 的 ManualCompactAgentContext 只在压缩已需使用的会话与路由事实之上增加 reserveTurnAdmission()。保留、平衡、摘要、标记排序、替换与持久性仍由后端负责。

可以同步预留空闲轮次接纳

Agent.reserveTurnAdmission(): (() => void) | undefined 会在下一个普通轮次之前认领边界。仅当驱动器空闲、没有既存预留,而且尚无已获接纳的唤醒项拥有下一轮次时,它才会成功;仍在等待 microtask 认领的唤醒项也包括在内。

该预留不会创建第二个队列。之后发送的项保留其 InboxItemId、位置、FIFO 顺序与唤醒信息。acceptsNextStep 保持 false,因此唤醒的 next-step 输入会成为普通的排队 follow-up,而不是 steering(中途引导)。释放操作可幂等调用,并重新启用既有驱动器路径。inject() 不受阻塞。

whenIdle() 会把预留视为尚未完成的活动,包括预留持有唤醒项的情况。生命周期 teardown 仍会排空驱动器自身的 activity promise,而不会等待外部操作,因此 dispose(资源释放)可以执行取消并完成退出清理,无需依赖预留持有方。

一个参数化事务拥有每一对标记

dsh-compact-basic 只有一个区域事务,由标记归属值(number | null)、稳定性规则(整个 surface 或所选 span)与可选 flush 参数化。它按同一顺序执行:

  1. 验证所选位置范围,并检查持久日志尾部;
  2. 拒绝活动的未匹配压缩标记;
  3. 同步追加 compact/start;
  4. 准备并等待摘要;
  5. 重新验证所需稳定性;
  6. 追加 compact/summary 与替换用的 user/message;
  7. 恰好尝试一次 compact/end;
  8. 当手动调用方要求持久性时执行 flush。

自动和显式区域工作使用从开放轮次恢复的数字归属值,并要求整个 surface 保持稳定。手动工作会先预留接纳,在进入事务前选择有效范围;选择结果为 null 时不写入任何内容。其标记对使用 turn: null,只要求所选 span 保持稳定,并在 finally 中释放接纳预留前 flush 每次成功闭合的尝试。

因此,compact/start 是唯一的压缩锁。不存在 WeakSet、包装层 mutex、locked/unlocked 方法拆分,也不存在事务外部重复的活动状态检查。

先记录标记有意不同于调研过的实现

Codex 将手动压缩建模为占用其活动轮次槽位的 CompactTask,自动压缩则以内联方式运行。Pi 使用压缩 abort controller 是否存在作为 mutex,并仅在成功后追加压缩。Claude Code 的自动和手动路径共享同一个压缩例程,但会在摘要流结束后才构造边界。

DSH 有意在调用摘要器前记录 compact/start。缓慢或崩溃的尝试因此可观察,自动与手动路径共享同一个持久锁,之后的写入方也不会把正在生成的摘要误判为未锁定会话。这是对先摘要行为的主动偏离,而不是偶然的事件顺序差异。

标记是时间点,而不是事件容器

compact/start 和 compact/end 表示获取与释放锁。它们不声称排他拥有二者 seq 之间的每个事件。手动摘要等待期间,空闲的 inject() 可以追加 user/message,因此该不相关事件可能位于标记区间内。

手动稳定性只检查所选 span:它必须仍然存在、连续、有序、计价相同且保持平衡。其外部的仅追加上下文不会使摘要陈旧。位置替换会把检查点放在旧 span 的 surface 位置,并使注入上下文在派生模型历史中位于其后,即使注入的日志 seq 早于后续摘要和替换事件。

失败的 changed 或 summary 尝试会保持会话 surface 不变,但日志并非没有变化:其中会包含 compact/start 和 compact/end { error }。面向用户的文本会明确说明这一区别。

End-seed 区分活动与陈旧的未匹配标记

尾部扫描会分别查找当前轮次、未匹配的 compaction start 与最新 session/end-seed。位于最新 end-seed 之后的未匹配 start 是活动锁,会阻塞每个压缩入口点。位于较新 end-seed 之前的未匹配 start 属于更早的会话生命周期,已经陈旧,因此不会卡住恢复或 fork 后的会话。

压缩不变量在 seed 回放期间使用同一项转换逻辑:session/end-seed 会清除开放的历史追踪状态。此场景不要求构造函数实时发布该边界;回放才是承重路径。

客户端请求投影会在 session/end-seed 时刻将未匹配的压缩请求以中断状态结束,并清除其活动索引。因此,后续 compact/start 会创建一个独立请求,而不是让该遗留的未匹配请求永久保持运行状态或将其覆盖。

事务追加 start 后,每次后续失败都会进行一次闭合尝试。闭合失败会有意留下可见且具有阻塞作用的未匹配 start,并且不尝试 flush。已闭合的手动尝试即使报告预期失败也会 flush。完成必需的闭合与 flush 清理后,取消仍保留原始原因优先级。

参考实现边界

PR #835 用作命令、预留、测试与快照结构的参考实现,但未被合并。它的进程本地 WeakSet 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。

该参考实现还包含客户端侧替换锚点机制,用于保留 transcript(文本记录)位置。按日志顺序排列的 transcript 投影已经从事件顺序消费压缩,并且不会查询可变 surface 位置,因此这些锚点经过评估后未被采用。

曾考虑的替代方案

仅检查 agent.status,不预留接纳。 不予采用,因为已获接纳的唤醒发送可能仍在等待其认领 microtask,而状态仍显示 idle。

把命令本身加入队列。 不予采用,因为 /compact 是直接控制而非模型输入;先获接纳的提示词必须保留优先权,不能围绕第二个命令队列重新排序。

在追加 compact/start 前生成摘要。 不予采用,因为开销较大的进行中操作将不可见,也不会参与自动压缩共享的锁。

同时使用持久标记与进程本地 mutex。 不予采用,因为两项权威在回放后可能产生分歧,还会要求用包装层分支处理标记对已经表达的状态。

与唤醒提示词一起阻塞注入。 不予采用,因为按契约,空闲注入是不会唤醒的持久上下文;延迟注入会使插件排序依赖某个 UI 命令。

要求标记区间只包含压缩事件。 不予采用,因为标记表示锁的时间点。溯源信息会精确指明所选 seq 与被遮蔽 seq;排他性不会增加正确性,只会拒绝有效注入。

把每个未匹配标记都永久视为 busy。 不予采用,因为崩溃恢复或 fork 后的会话会永久卡住。session/end-seed 是区分陈旧历史与当前进程活动尝试的显式生命周期证据。

验证

Agent loop 测试覆盖同一 tick 内的优先权、保留 ID 与 FIFO 生命周期、会唤醒和静默的排队工作、幂等释放、whenIdle()、取消与 teardown。压缩测试覆盖独立与数字形式的不变量 owner、end-seed 回放、活动与陈旧未匹配标记、listener 重入、所选 span 漂移、commit 与闭合失败、flush 顺序、原始取消原因、raw output 与 usage 保留,以及自动/手动互斥。

命令包固定注册行为、Loader 组合、参数拒绝、精确的成功/失败文本、取消、不进入模型历史的保证,以及处置操作在中止使执行器停止等待处理器后,仍会跨越相互独立的闭合与 flush 边界等待该处理器结算。客户端运行时投影测试固定 end-seed 中断,以及随后一次独立尝试的完成。queued-manual-compact 终端快照通过已组装 TUI 驱动真实按键:/help 可发现该命令;被暂停的摘要会接纳一个排队提示词和即时注入;turn: null 标记与 flush 先于排队提示词轮次;命令生命周期保持纯日志;派生顺序固定为检查点 → 注入 → 排队提示词。

后果

交互用户无需消耗会话模型轮次即可压缩有效历史。在命令前获接纳的提示词胜出;命令期间提交的提示词会以原有队列身份等待。手动压缩会消耗会话 seq,但不消耗轮次编号。

日志通过同一对标记暴露缓慢、失败、崩溃与成功的尝试。边界前的陈旧未匹配标记不会再卡住新的生命周期,而当前未匹配 start 仍是严格的 busy 信号。标记区间可以包含不相关事件,因此消费方使用溯源信息与相对顺序,而不假定存在连续且仅含压缩事件的切片。

共享事务让每个入口点保持同一种顺序并使用同一把锁。失败报告会精确区分只有日志发生变化、surface 可能部分改变,以及内存 commit 无法持久化这三种情况。