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

386 lines
8.8 KiB
Go

package gateway
import (
"context"
"log/slog"
"sort"
"strings"
"sync"
"time"
runtimelogs "github.com/nextlevelbuilder/goclaw/internal/logs"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
const (
ringBufferSize = 100
redactedValue = "***"
)
// sensitiveKeys are attribute keys whose values are redacted before forwarding.
var sensitiveKeys = []string{
"key", "token", "secret", "password", "dsn",
"credential", "authorization", "cookie",
}
var safeTokenTelemetryKeys = map[string]bool{
"total_tokens": true,
"prompt_tokens": true,
"completion_tokens": true,
"input_tokens": true,
"output_tokens": true,
"thinking_tokens": true,
"last_prompt_tokens": true,
"last_usage_prompt_tokens": true,
"total_prompt_tokens": true,
}
// LogTee is a slog.Handler that forwards log records to subscribed WS clients
// while delegating to an underlying handler for normal output.
type LogTee struct {
inner slog.Handler
state *logTeeState
attrs []slog.Attr
}
type logTeeState struct {
mu sync.RWMutex
clients map[string]*logSubscriber
// Ring buffer of recent entries for replay on subscribe.
ringMu sync.RWMutex
ring []map[string]any
ringPos int
ringFul bool
}
// logSubscriber tracks a client and its requested minimum log level.
type logSubscriber struct {
client *Client
level slog.Level
}
// NewLogTee wraps an existing slog.Handler so log records are also forwarded
// to any WebSocket clients that have started log tailing.
func NewLogTee(inner slog.Handler) *LogTee {
return &LogTee{
inner: inner,
state: &logTeeState{
clients: make(map[string]*logSubscriber),
ring: make([]map[string]any, ringBufferSize),
},
}
}
func (t *LogTee) Enabled(ctx context.Context, level slog.Level) bool {
// Always accept if inner handler wants it.
if t.inner.Enabled(ctx, level) {
return true
}
// Also accept if any subscriber wants this level (e.g. debug).
t.state.mu.RLock()
defer t.state.mu.RUnlock()
for _, sub := range t.state.clients {
if level >= sub.level {
return true
}
}
return false
}
func (t *LogTee) Handle(ctx context.Context, r slog.Record) error {
// Build entry for WS clients.
t.state.mu.RLock()
n := len(t.state.clients)
t.state.mu.RUnlock()
needEntry := n > 0 // need to broadcast
// Always build entry for ring buffer regardless of subscribers.
entry := t.buildEntry(r)
// Store in ring buffer.
t.state.ringMu.Lock()
t.state.ring[t.state.ringPos] = entry
t.state.ringPos = (t.state.ringPos + 1) % ringBufferSize
if t.state.ringPos == 0 {
t.state.ringFul = true
}
t.state.ringMu.Unlock()
// Forward to subscribers.
if needEntry {
evt := protocol.NewEvent("log", entry)
level := r.Level
t.state.mu.RLock()
for _, sub := range t.state.clients {
if level >= sub.level {
sub.client.SendEvent(*evt)
}
}
t.state.mu.RUnlock()
}
// Forward to inner handler only if it accepts this level.
if t.inner.Enabled(ctx, r.Level) {
return t.inner.Handle(ctx, r)
}
return nil
}
// buildEntry creates the WS payload from a log record, redacting sensitive attrs.
func (t *LogTee) buildEntry(r slog.Record) map[string]any {
entry := map[string]any{
"timestamp": r.Time.UnixMilli(),
"level": levelName(r.Level),
"message": r.Message,
}
attrs := map[string]any{}
for _, a := range t.attrs {
applyLogAttr(entry, attrs, a)
}
r.Attrs(func(a slog.Attr) bool {
applyLogAttr(entry, attrs, a)
return true
})
if len(attrs) > 0 {
entry["attrs"] = attrs
}
return entry
}
func (t *LogTee) WithAttrs(attrs []slog.Attr) slog.Handler {
nextAttrs := make([]slog.Attr, 0, len(t.attrs)+len(attrs))
nextAttrs = append(nextAttrs, t.attrs...)
nextAttrs = append(nextAttrs, attrs...)
return &LogTee{
inner: t.inner.WithAttrs(attrs),
state: t.state,
attrs: nextAttrs,
}
}
func (t *LogTee) WithGroup(name string) slog.Handler {
return &LogTee{
inner: t.inner.WithGroup(name),
state: t.state,
attrs: append([]slog.Attr(nil), t.attrs...),
}
}
// Subscribe adds a client to the log tailing set at the given level.
// Pass slog.LevelInfo for default, slog.LevelDebug for verbose.
func (t *LogTee) Subscribe(client *Client, level slog.Level) {
t.state.mu.Lock()
t.state.clients[client.ID()] = &logSubscriber{client: client, level: level}
t.state.mu.Unlock()
// Replay ring buffer entries at the requested level.
t.state.ringMu.RLock()
var entries []map[string]any
if t.state.ringFul {
// Buffer is full — read from ringPos (oldest) to ringPos-1 (newest).
for i := range ringBufferSize {
idx := (t.state.ringPos + i) % ringBufferSize
e := t.state.ring[idx]
if e != nil && logLevelValue(e["level"]) >= level {
entries = append(entries, e)
}
}
} else {
// Buffer not full — read from 0 to ringPos-1.
for i := 0; i < t.state.ringPos; i++ {
e := t.state.ring[i]
if e != nil && logLevelValue(e["level"]) >= level {
entries = append(entries, e)
}
}
}
t.state.ringMu.RUnlock()
for _, e := range entries {
client.SendEvent(*protocol.NewEvent("log", e))
}
// Send sentinel so the client knows tailing started.
client.SendEvent(*protocol.NewEvent("log", map[string]any{
"timestamp": time.Now().UnixMilli(),
"level": "info",
"message": "Log tailing started",
"source": "gateway",
}))
}
// Unsubscribe removes a client from the log tailing set.
func (t *LogTee) Unsubscribe(clientID string) {
t.state.mu.Lock()
delete(t.state.clients, clientID)
t.state.mu.Unlock()
}
func (t *LogTee) AggregateRuntimeLogs(opts runtimelogs.RuntimeAggregateOpts) runtimelogs.RuntimeAggregateResult {
groupBy := opts.GroupBy
if groupBy == "" {
groupBy = "level"
}
entries := t.recentEntries()
counts := map[string]runtimelogs.RuntimeAggregateBucket{}
sampleSize := 0
for _, e := range entries {
if opts.FromMS > 0 && int64Value(e["timestamp"]) < opts.FromMS {
continue
}
level := stringValue(e["level"])
if opts.Level != "" && level != opts.Level {
continue
}
source := stringValue(e["source"])
if opts.Source != "" && source != opts.Source {
continue
}
key := level
if groupBy == "source" {
key = source
if key == "" {
key = "unknown"
}
}
sampleSize++
b := counts[key]
b.Key = key
b.Count++
ts := int64Value(e["timestamp"])
if ts > b.LastSeen {
b.LastSeen = ts
}
counts[key] = b
}
buckets := make([]runtimelogs.RuntimeAggregateBucket, 0, len(counts))
for _, b := range counts {
buckets = append(buckets, b)
}
sort.Slice(buckets, func(i, j int) bool {
if buckets[i].Count == buckets[j].Count {
return buckets[i].LastSeen > buckets[j].LastSeen
}
return buckets[i].Count > buckets[j].Count
})
return runtimelogs.RuntimeAggregateResult{
Source: "runtime",
Retention: "ring_buffer",
Capacity: ringBufferSize,
SampleSize: sampleSize,
GroupBy: groupBy,
Buckets: buckets,
}
}
func (t *LogTee) recentEntries() []map[string]any {
t.state.ringMu.RLock()
defer t.state.ringMu.RUnlock()
entries := make([]map[string]any, 0, ringBufferSize)
if t.state.ringFul {
for i := range ringBufferSize {
idx := (t.state.ringPos + i) % ringBufferSize
if e := t.state.ring[idx]; e != nil {
entries = append(entries, cloneLogEntry(e))
}
}
return entries
}
for i := 0; i < t.state.ringPos; i++ {
if e := t.state.ring[i]; e != nil {
entries = append(entries, cloneLogEntry(e))
}
}
return entries
}
func cloneLogEntry(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func stringValue(v any) string {
s, _ := v.(string)
return s
}
func int64Value(v any) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case int32:
return int64(n)
case float64:
return int64(n)
default:
return 0
}
}
func levelName(l slog.Level) string {
switch {
case l >= slog.LevelError:
return "error"
case l >= slog.LevelWarn:
return "warn"
case l >= slog.LevelInfo:
return "info"
default:
return "debug"
}
}
// logLevelValue converts a level name string back to slog.Level for filtering.
func logLevelValue(v any) slog.Level {
s, _ := v.(string)
switch s {
case "error":
return slog.LevelError
case "warn":
return slog.LevelWarn
case "info":
return slog.LevelInfo
case "debug":
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
func isSensitiveKey(key string) bool {
lower := strings.ToLower(key)
if safeTokenTelemetryKeys[lower] {
return false
}
for _, s := range sensitiveKeys {
if strings.Contains(lower, s) {
return true
}
}
return false
}
func applyLogAttr(entry, attrs map[string]any, a slog.Attr) {
key := a.Key
val := a.Value.String()
if key == "component" || key == "source" || key == "module" {
entry["source"] = val
return
}
if isSensitiveKey(key) {
attrs[key] = redactedValue
return
}
attrs[key] = val
}