fix(compact-basic): fence surface changes only

This commit is contained in:
Tianyi Cui
2026-07-21 16:34:28 +08:00
parent 6475e51825
commit 5261ebab38
11 changed files with 39 additions and 17 deletions

View File

@@ -14,7 +14,7 @@ This backend owns the compaction policy:
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.

View File

@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-compact-basic/region
*/
import { isDeepStrictEqual } from 'node:util'
import {
renderTranscript,
toolPairingBalancedAfter,
@@ -113,8 +114,8 @@ export async function compactSurfaceRegion(
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
const startEvent = session.append('compact/start', { turn: tail.turn })
try {
// Capture after the lock event so any later durable append, including a
// log-only one, invalidates the async selection before replacement.
// Capture after the lock event so a later surface mutation invalidates the
// async selection before replacement. Unrelated log-only facts may append.
const lockedMeasurement = dependencies.meter.measure(session)
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
@@ -126,8 +127,8 @@ export async function compactSurfaceRegion(
const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
throw new Error('compaction: session log changed during summarization')
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
throw new Error('compaction: session surface changed during summarization')
}
const framedSummary = frameSummary(summary)
const framedSummaryTokenCount = dependencies.meter.estimateMessage({

View File

@@ -683,13 +683,13 @@ describe('compaction region transaction', () => {
.toMatchObject({ error: 'plain failure' })
})
it('rejects concurrent durable appends before committing the replacement', async () => {
it('tolerates concurrent log-only appends while the selected surface is stable', async () => {
const compact = service()
const session = conversation(2)
compact.mutateDuringSummary = () => {
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
reason: 'change',
})
}
const nodes = session.surface.nodes
@@ -698,7 +698,26 @@ describe('compaction region transaction', () => {
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/session log changed/)
)).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) })
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
})
it('rejects concurrent surface appends before committing the replacement', async () => {
const compact = service()
const session = conversation(2)
compact.mutateDuringSummary = () => {
session.append('context/message', {
content: [{ type: 'text', text: 'concurrent surface mutation' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}
const nodes = session.surface.nodes
await expect(compact.compactRegion(
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/session surface changed/)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})

View File

@@ -47,7 +47,7 @@ The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is
## Blocking
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
## Events