Status: implemented
English | 中文
Automatic compaction protects the context window, but an interactive user also needs a deterministic way to condense accumulated history before pressure policy fires. Sending /compact as prompt text would spend a model turn and let the conversation model reinterpret a direct control action. Implementing it inside one UI would duplicate command discovery, lifecycle logging, cancellation, and backend policy.
The human command arrives between turns and must summarize asynchronously. A prompt accepted during that wait must keep its ordinary identity, FIFO position, and wakeup behavior, but it must not derive a request from history that compaction is about to replace. A separate status check is insufficient because another caller can wake the driver between that check and the compaction operation claiming the idle phase.
Compaction also needs one mutual-exclusion fact shared by manual, pressure, overflow, and explicit-range entry points. A process-local flag alone cannot explain a crash-recovered log, while a summarize-first transaction leaves no durable evidence during the expensive interval. Conversely, treating marker pairs as exclusive containers would forbid valid idle injection even though injection is explicitly non-waking and immediate between turns.
This note extends the compaction capability seam, the session end-seed boundary, and the removal of synthetic log-only turns. Each remains active and owns its broader decision; the overlap is partial only.
@deepseek-ai/dsh-command-compact registers one argument-free human command through ctx.commands. It calls the third abstract CompactionEngine operation, compactNow(agent, signal), and maps the closed ManualCompactionError taxonomy (busy | changed | summary | commit | persistence) to direct UI results. command/run and command/done preserve the command lifecycle without entering model history or consuming a model-loop turn.
The command plugin tracks each real handler promise independently of the command executor's abort-aware wait. Its composite lifecycle effect unregisters /compact before asynchronously draining handlers that already started, so root teardown reaches quiescence only after backend close and flush work settles.
The seam's ManualCompactAgentContext adds only runMaintenance() to the session and routing facts compaction already needs. Retention, balancing, summarization, marker ordering, replacement, and durability remain backend responsibilities.
Agent.runMaintenance(task) starts only from the idle phase and claims that phase before invoking the task. A waking send starts the loop immediately when idle, so whichever operation claims the phase first owns the boundary.
Maintenance does not create a second queue. Later sends keep their MessageId, placement, FIFO order, and wakeup facts. Waking input remains queued until maintenance settles, then starts the existing driver path; inject() remains non-waking.
whenIdle() treats maintenance and any waking work released behind it as unfinished activity. Cancellation aborts the agent-owned maintenance signal, and lifecycle teardown drains the same activity boundary before disposal completes.
dsh-compaction-basic has one region transaction parameterized by bracket owner (number | null), stability rule (whole surface or selected span), and an optional flush. It performs one ordering:
- validate the selected positional range and inspect the durable tail;
- reject a live unmatched compaction marker;
- append
compaction/startsynchronously; - prepare and await summarization;
- revalidate the required stability;
- append
compaction/summaryand the replacementuser/message; - make exactly one
compaction/endattempt; - flush when the manual caller requested durability.
Automatic and explicit-region work use the numeric owner recovered from the open turn and require whole-surface stability. Manual work reserves admission first, selects a useful range before the transaction, and writes nothing when selection returns null. Its bracket uses turn: null, requires only selected-span stability, and flushes every successfully closed attempt before releasing admission in finally.
compaction/start is therefore the only compaction lock. There is no WeakSet, wrapper mutex, locked/unlocked method split, or redundant activity check around the transaction.
Codex models manual compaction as a CompactionTask occupying its active-turn slot while automatic compaction runs inline. Pi uses the existence of a compaction abort controller as its mutex and appends compaction only after success. Claude Code shares one compaction routine between automatic and manual paths but constructs its boundary after summary streaming.
DSH deliberately records compaction/start before calling the summarizer. A slow or crashed attempt is observable, automatic and manual paths share the same durable lock, and a later writer cannot mistake an in-flight summary for an unlocked session. This is a conscious divergence from summarize-first behavior, not an accidental event-order difference.
compaction/start and compaction/end mean lock acquisition and release. They do not claim exclusive ownership of every event between their seqs. An idle inject() may append a user/message while a manual summary is pending, so that unrelated event can sit inside the marker interval.
Manual stability checks only the selected span: it must remain present, contiguous, ordered, equally priced, and balanced. Append-only context outside it does not stale the summary. Positional replacement places the checkpoint at the old span's surface position and leaves injected context after it in derived model history, even though the injection's log seq precedes the later summary and replacement events.
Failed changed or summary attempts leave the conversation surface unchanged, but the log is not unchanged: it contains compaction/start and compaction/end { error }. User-facing text states that distinction.
Tail scanning finds the current turn, unmatched compaction start, and newest session/end-seed independently. An unmatched start after the newest end-seed is live and blocks every compaction entry point. An unmatched start before a later end-seed belongs to an earlier session lifecycle and is stale, so it does not wedge the resumed or forked session.
The compaction invariant uses the same transition logic during seed replay: session/end-seed clears an open historical trace. The boundary need not publish live from the constructor for this case; replay is the load-bearing path.
The client request projection closes an unmatched compaction request as interrupted at the session/end-seed time and clears its active index. A later compaction/start therefore creates an independent request instead of leaving or overwriting a permanently running orphan.
Once a transaction has appended its start, every later failure makes one closing attempt. A failed close leaves the unmatched start deliberately visible and blocking, and no flush is attempted. A closed manual attempt is flushed even when it reports an expected failure. Cancellation retains exact-reason precedence after required close and flush cleanup.
An unmerged reference implementation informed the command, reservation, tests, and snapshot shape. Its process-local WeakSet lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock.
That reference also carried client-side replacement-anchor machinery to preserve transcript placement. The log-ordered transcript projection already consumes compaction from event order and does not consult mutable surface positions, so those anchors were considered and not adopted.
Check agent.status before starting maintenance. Rejected because the check and phase claim would be separate operations; a waking send could start the driver between them.
Queue the command itself. Rejected because /compact is direct control, not model input, and a prompt already accepted first must retain right of way rather than being reordered around a second command queue.
Summarize before appending compaction/start. Rejected because the expensive in-flight operation would be invisible and would not participate in the lock shared by automatic compaction.
Use both a durable marker and a process-local mutex. Rejected because two authorities can disagree after replay and require wrapper branches for states the bracket already expresses.
Hold injection with waking prompts. Rejected because idle injection is non-waking durable context by contract; delaying it would make plugin ordering depend on a UI command.
Require the marker interval to contain only compaction events. Rejected because markers represent lock time points. compaction/summary names the selected range and shadowed seqs exactly; exclusivity would add no correctness and would reject valid injection.
Treat every unmatched marker as permanently busy. Rejected because a crash-recovered or forked session would remain wedged. session/end-seed is the explicit lifecycle evidence that distinguishes stale history from a live process-local attempt.
Agent-loop tests cover same-tick right of way, preserved IDs and FIFO lifecycle, waking and quiet queued work, idempotent release, whenIdle(), cancellation, and teardown. Compact tests cover standalone and numbered invariant ownership, end-seed replay, live versus stale orphans, re-entrant listeners, selected-span drift, commit and close failures, flush ordering, exact cancellation causes, raw output and usage preservation, and automatic/manual mutual exclusion.
The command package pins registration, Loader composition, argument rejection, exact success/failure text, cancellation, absence from model history, and disposal waiting across separate close and flush boundaries after an abort stops the executor from awaiting the handler. The client runtime projection test pins end-seed interruption followed by an independent completed attempt. The queued-manual-compact terminal snapshot drives real keystrokes through the assembled TUI: /help discovers the command, a held summary admits a queued prompt and immediate injection, turn: null markers and the flush precede the queued prompt turn, command lifecycle stays log-only, and the derived order is checkpoint → injection → queued prompt.
Interactive users can compact useful history without spending a conversation-model turn. A prompt accepted before the command wins; one submitted during the command waits with its original queue identity. Manual compaction consumes session seqs but no turn number.
The log exposes slow, failed, crashed, and successful attempts through the same bracket. A stale pre-boundary orphan no longer wedges a new lifecycle, while a current unmatched start remains a hard busy signal. Marker intervals may contain unrelated events, so consumers use the seqs recorded in compaction/summary and relative ordering rather than assuming a contiguous compaction-only slice.
The shared transaction keeps one ordering and one lock across every entry point. Failure reporting is precise about whether only the log changed, the surface may have partially changed, or the in-memory commit could not be persisted.