Files
Clark Cant 5ca433b8ac fix: persist mid-loop compaction to stop re-compaction loop and revive episodic
The v3 pipeline compacts session history mid-loop (prune_stage +
final-request guard) but only mutates the run's message buffer, never the
session store. Each turn reloads full history and re-compacts from scratch:
message_tokens climb 129k->156k across turns while every turn compacts back
down to ~60k. The lossy compaction differs per run, degrading the agent.

The same missing persistence stalls episodic memory: the cumulative
compaction count never advances, so the episodic worker's idempotency key
(sessionKey:count) is pinned and every cycle after the first is skipped.
Observed on live traffic: 8 run.completed since deploy, 0 new episodic.

Fixes, all reusing existing machinery (no new store methods, no migrations):

- Bug A: emitSessionCompleted reads cumulative GetCompactionCount (matching
  the legacy v2 path) instead of the per-run counter that resets to 0.
- Bug B/anti-loop: finalize passes state.Prune.MidLoopCompacted into
  maybeSummarize; under pressure it lowers the trigger to a unit-aligned
  threshold (compactionInputCap - overhead, same MaxRequestShare the guard
  uses) so the compaction is PERSISTED via the existing TruncateHistory +
  IncrementCompaction path. Defensive floor prevents over-compaction on
  pathological config; tool-result-only bloat still skips (history-only).
- Bug C: SourceID embeds the count (sessionKey:count) so the eventbus dedup
  key advances per compaction cycle instead of swallowing rapid same-session
  turns within the 5m TTL.

Tests: episodic compaction, maybe_summarize pressure, request budget.
go build (PG + sqliteonly), go vet, go test -race all green.
2026-07-25 00:39:02 +07:00

83 lines
3.1 KiB
Go

package consolidation
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// budgetAgentStore overrides only GetByIDUnscoped; the embedded nil interface
// panics if any other method is called, which keeps the mock honest.
type budgetAgentStore struct {
store.AgentCRUDStore
agent *store.AgentData
err error
}
func (m *budgetAgentStore) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
if m.err != nil {
return nil, m.err
}
return m.agent, nil
}
// The whole point of the fix: background workers must carry the agent's
// configured request budget into ctx, or the agent-only preflight guard fails
// closed with AgentBudgetWiringError and silently kills memory consolidation.
func TestWithAgentRequestBudget_WiresConfiguredAgentBudget(t *testing.T) {
id := uuid.New()
agents := &budgetAgentStore{agent: &store.AgentData{ContextWindow: 128000, MaxTokens: 4096}}
ctx := withAgentRequestBudget(context.Background(), agents, id, "episodic-summary")
if got := store.AgentContextWindowFromContext(ctx); got != 128000 {
t.Fatalf("context window: want 128000, got %d", got)
}
if got := store.AgentMaxTokensFromContext(ctx); got != 4096 {
t.Fatalf("max tokens: want 4096, got %d", got)
}
}
func TestWithAgentRequestBudget_NilStoreFallsBackToDefaults(t *testing.T) {
ctx := withAgentRequestBudget(context.Background(), nil, uuid.New(), "dreaming-synthesis")
if got := store.AgentContextWindowFromContext(ctx); got != config.DefaultContextWindow {
t.Fatalf("context window: want default %d, got %d", config.DefaultContextWindow, got)
}
if got := store.AgentMaxTokensFromContext(ctx); got != config.DefaultMaxTokens {
t.Fatalf("max tokens: want default %d, got %d", config.DefaultMaxTokens, got)
}
}
func TestWithAgentRequestBudget_LookupErrorFallsBackToDefaults(t *testing.T) {
agents := &budgetAgentStore{err: errors.New("db down")}
ctx := withAgentRequestBudget(context.Background(), agents, uuid.New(), "episodic-summary")
if got := store.AgentContextWindowFromContext(ctx); got != config.DefaultContextWindow {
t.Fatalf("context window: want default %d, got %d", config.DefaultContextWindow, got)
}
if got := store.AgentMaxTokensFromContext(ctx); got != config.DefaultMaxTokens {
t.Fatalf("max tokens: want default %d, got %d", config.DefaultMaxTokens, got)
}
}
// A zero/partial agent row must not zero out the budget — the setters ignore
// non-positive values, and the helper must supply defaults for the missing half.
func TestWithAgentRequestBudget_ZeroFieldsUseDefaults(t *testing.T) {
agents := &budgetAgentStore{agent: &store.AgentData{ContextWindow: 0, MaxTokens: 0}}
ctx := withAgentRequestBudget(context.Background(), agents, uuid.New(), "episodic-summary")
if got := store.AgentContextWindowFromContext(ctx); got != config.DefaultContextWindow {
t.Fatalf("context window: want default %d, got %d", config.DefaultContextWindow, got)
}
if got := store.AgentMaxTokensFromContext(ctx); got != config.DefaultMaxTokens {
t.Fatalf("max tokens: want default %d, got %d", config.DefaultMaxTokens, got)
}
}