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

Status: implemented

[English](2026-07-30-queued-manual-compaction.md) | 中文

## 问题

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

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

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

本 Agent Note 扩展[压缩能力 seam](2026-06-18-compaction-capability-seam.md)、[会话 end-seed 边界](../architecture/2026-07-30-session-end-seed-log-boundary.md)和[移除纯日志事件的合成轮次](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)。取代关系审计只发现部分重叠：三者均保持活动状态，并拥有各自更广泛的决策。

## 决策

### `/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](https://github.com/deepseek-harness/deepseek-harness/pull/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 无法持久化这三种情况。
