Files
DeepSeek-Reasonix/internal/agent/compact_commit.go
T
SivanCola bf979b7831 Preserve error ownership across compaction entry points
Problem: Compaction entry points relabelled image, extension, persistence and
validation failures as summary errors. An expired safe-pressure operation could
replace a later error with its budget timeout and discard recovery guidance.

Root cause: The shared finish wrapper classified every returned error using the
work context state instead of the owner and identity of the failure.

Fix: Classify provider failures only at the summary request and known summary
outcomes at their owner. Normalize deadlines only when the returned error and
work budget both identify expiry. Preserve wrapped causes and apply localized
context recovery guidance last. Restore oversized protected tool-result
coverage through successful summarization without truncation.

Verification: Regression tests reproduce missing-image classification, safe
timeout followed by extension blocks, persistence identity, compress validation
and localized hard-limit recovery. Check actual summary failure typing and the
protected-result replay with intact canonical history. Run focused race tests,
pinned lint, repolint and the complete root suite; track exact-head PR CI.
2026-09-28 18:57:11 +08:00

147 lines
6.1 KiB
Go

package agent
import (
"context"
"errors"
"fmt"
"time"
"reasonix/internal/provider"
)
type summaryProjectionCommit struct {
canonical, fold, projected []provider.Message
result foldSummary
transcriptVersion, projectionVersion, generation uint64
activeTurn int64
trigger, summary, inputHash, outputHash string
sourceTokens, projectionTokens int
// covered is the canonical length the frozen projection body represents;
// messages past it splice live from the transcript.
covered int
}
// commitSummaryProjection CAS-installs a checkpoint under compactionMu:
// transcript version/hash, projection version, and generation must still match.
// The maintenance event is emitted only after the lock is released so a sink
// that re-enters ContextMaintenanceSnapshot cannot deadlock.
func (a *Agent) commitSummaryProjection(ctx context.Context, commit summaryProjectionCommit) (CompactionState, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CompactionState{}, err
}
state := a.summaryProjectionState(commit)
a.sess.compactionMu.Lock()
// This is the shared commit boundary for ordinary, positional, and fallback
// summary projection installs. Cancellation that wins before this point
// prevents every variant from publishing a late summary.
if err := ctx.Err(); err != nil {
a.sess.compactionMu.Unlock()
return CompactionState{}, err
}
current, currentVersion := a.sess.conversation.snapshotMessagesVersion()
if currentVersion != commit.transcriptVersion ||
len(current) != len(commit.canonical) ||
coveredPrefixHash(current, len(current)) != coveredPrefixHash(commit.canonical, len(commit.canonical)) ||
a.sess.compactionState.Projection.ProjectionVersion != commit.projectionVersion ||
a.sess.compactionState.Generation != commit.generation {
a.sess.compactionMu.Unlock()
return CompactionState{}, summaryError(errCompressStaleContext)
}
prev := a.sess.compactionState
a.sess.compactionState = state
// After installation begins we finish its consistency and durability work;
// cancellation may prevent a later batch but cannot tear this batch in half.
accepted, err := a.persistInstalledProjectionLocked(context.Background(), state, current)
if err != nil {
if accepted {
a.sess.checkpointState = "pending"
a.sess.compactionMu.Unlock()
return CompactionState{}, &compactionPersistenceError{fmt.Errorf("persist projection: %w", err)}
}
a.sess.compactionState = prev
a.sess.compactionMu.Unlock()
if errors.Is(err, errCompressStaleContext) {
return CompactionState{}, err
}
return CompactionState{}, &compactionPersistenceError{fmt.Errorf("persist projection: %w", err)}
}
a.sess.checkpointState = "applied"
if commit.activeTurn != 0 && commit.trigger != CompactionTriggerManual {
a.sess.compaction.lastTurn.Store(commit.activeTurn)
}
receipt := state.LastReceipt
a.sess.compactionMu.Unlock()
a.emitContextMaintenance(receipt)
return state, nil
}
func (a *Agent) persistInstalledProjectionLocked(ctx context.Context, state CompactionState, canonical []provider.Message) (bool, error) {
accepted := false
if recorder, ok := a.svc.sessionCheckpointer.(SessionModelContextRecorder); ok {
visible := modelVisibleFromProjection(state.Projection, canonical)
commit := cloneSessionModelContextCommit(SessionModelContextCommit{
OperationID: state.LastReceipt.OperationID,
Reason: state.LastReceipt.Action,
Messages: visible,
})
result, err := recorder.RecordSessionModelContext(ctx, commit)
accepted = result.Accepted
if err != nil {
if accepted {
a.sess.pendingModelContextCommit = &commit
}
return accepted, err
}
if result.Accepted && !result.Durable {
a.sess.pendingModelContextCommit = &commit
return true, errors.New("model context commit was accepted but is not durable")
}
}
if err := a.persistCompactionStateLocked(); err != nil {
if accepted {
visible := modelVisibleFromProjection(state.Projection, canonical)
commit := cloneSessionModelContextCommit(SessionModelContextCommit{
OperationID: state.LastReceipt.OperationID,
Reason: state.LastReceipt.Action,
Messages: visible,
})
a.sess.pendingModelContextCommit = &commit
}
return accepted, err
}
a.sess.pendingModelContextCommit = nil
return accepted, nil
}
func (a *Agent) summaryProjectionState(commit summaryProjectionCommit) CompactionState {
projectionVersion := commit.projectionVersion + 1
now := time.Now().UTC()
summaryHash := summaryContentHash(commit.summary)
coveredHash := coveredPrefixHash(commit.canonical, commit.covered)
receipt := &ContextMaintenanceReceipt{
OperationID: fmt.Sprintf("summary-%d-%s", projectionVersion, commit.outputHash), Status: "applied",
Action: "summary", Trigger: commit.trigger, SourceProjection: commit.projectionVersion,
ProjectionVersion: projectionVersion, CoveredCount: commit.covered, CoveredPrefixHash: coveredHash,
InputHash: commit.inputHash, OutputHash: commit.outputHash, InputTokens: commit.sourceTokens,
ResultTokens: commit.projectionTokens, SavedTokens: max(0, commit.sourceTokens-commit.projectionTokens),
SummaryHash: summaryHash, CacheBreak: true, CreatedAt: now,
}
// LastReceipt is authoritative; do not mirror last_trigger/last_mode/token
// counters or top-level blocked_* fields (stripped again on save).
return CompactionState{
SchemaVersion: compactionStateSchemaCurrent, TranscriptVersion: commit.transcriptVersion,
Generation: commit.generation + 1, PromptCacheKey: a.currentPromptCacheKey(),
Projection: ContextProjection{
Messages: commit.projected, TranscriptVersion: commit.transcriptVersion,
ProjectionVersion: projectionVersion, CoveredCount: commit.covered, CoveredPrefixHash: coveredHash,
PinnedContextHash: pinnedContextCoverageHash(commit.canonical, commit.covered),
SummaryHash: summaryHash, SourceTokens: commit.sourceTokens, ProjectionTokens: commit.projectionTokens,
ViewInputHash: commit.inputHash, ViewOutputHash: commit.outputHash, CreatedAt: now,
},
LastReceipt: receipt, UpdatedAt: now,
}
}