Skip to content

Commit 9cbd300

Browse files
committed
fix(codebuff): timestamp rollover, incremental cutoff, and bounded work test
1. Timestamp rollover: Track prevHour only for time-only timestamps. Reset prevHour when a non-time-only timestamp is encountered to prevent cross-format rollover errors. 2. Incremental cutoff: Added stat-only composite for Codebuff in discoveredFileEffectiveMtime. This prevents the full fingerprint from being computed for every session during incremental sync. 3. Bounded work test: Added TestSyncAllCodebuffBoundedPerEventWork that verifies unchanged sessions are skipped without reading transcript bytes, and only modified sessions trigger a reparse.
1 parent d743d17 commit 9cbd300

2 files changed

Lines changed: 125 additions & 5 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package sync_test
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"go.kenn.io/agentsview/internal/dbtest"
14+
"go.kenn.io/agentsview/internal/parser"
15+
"go.kenn.io/agentsview/internal/sync"
16+
)
17+
18+
// writeCodebuffTestFiles creates the three files that make up a Codebuff
19+
// session directory: chat-messages.json, run-state.json, and chat-meta.json.
20+
func writeCodebuffTestFiles(t *testing.T, dir, content string) {
21+
t.Helper()
22+
chatPath := filepath.Join(dir, "chat-messages.json")
23+
runStatePath := filepath.Join(dir, "run-state.json")
24+
chatMetaPath := filepath.Join(dir, "chat-meta.json")
25+
26+
require.NoError(t, os.WriteFile(chatPath, []byte(`[
27+
{"id":"user-1","variant":"user","content":"`+content+`","timestamp":"03:04 PM"}
28+
]`), 0o644))
29+
require.NoError(t, os.WriteFile(runStatePath, []byte(`{
30+
"sessionState": {
31+
"mainAgentState": {"agentType": "base2-free-deepseek"}
32+
}
33+
}`), 0o644))
34+
require.NoError(t, os.WriteFile(chatMetaPath, []byte(`{
35+
"messageCount": 1,
36+
"firstPrompt": "`+content+`",
37+
"messagesSize": 50
38+
}`), 0o644))
39+
}
40+
41+
// TestSyncAllCodebuffBoundedPerEventWork verifies that unchanged Codebuff
42+
// sessions are skipped during reconciliation without reading transcript
43+
// bytes. The stat-only freshness gate (providerSourceFreshBeforeFingerprint)
44+
// should prevent the fingerprint from being called for unchanged sources.
45+
func TestSyncAllCodebuffBoundedPerEventWork(t *testing.T) {
46+
if testing.Short() {
47+
t.Skip("skipping integration test")
48+
}
49+
50+
// Create a small archive with 3 sessions under 2 projects.
51+
root := t.TempDir()
52+
projects := []string{"project-a", "project-b"}
53+
sessionTimestamps := []string{
54+
"2026-07-15T10-00-00.000Z",
55+
"2026-07-15T11-00-00.000Z",
56+
"2026-07-15T12-00-00.000Z",
57+
}
58+
59+
for _, project := range projects {
60+
for _, ts := range sessionTimestamps {
61+
dir := filepath.Join(root, project, "chats", ts)
62+
require.NoError(t, os.MkdirAll(dir, 0o755))
63+
writeCodebuffTestFiles(t, dir, "Hello from "+project+"/"+ts)
64+
}
65+
}
66+
67+
database := dbtest.OpenTestDB(t)
68+
engine := sync.NewEngine(database, sync.EngineConfig{
69+
AgentDirs: map[parser.AgentType][]string{
70+
parser.AgentCodebuff: {root},
71+
},
72+
Machine: "local",
73+
})
74+
75+
// First sync: all sessions should be parsed.
76+
synced := engine.SyncAll(context.Background(), nil).Synced
77+
assert.Equal(t, 6, synced, "first sync should parse all 6 sessions")
78+
79+
// Second sync with no changes: all sessions should be skipped.
80+
synced = engine.SyncAll(context.Background(), nil).Synced
81+
assert.Equal(t, 0, synced, "second sync with no changes should skip all sessions")
82+
83+
// Modify one session's chat-messages.json.
84+
modifiedDir := filepath.Join(root, projects[0], "chats", sessionTimestamps[0])
85+
modifiedChatPath := filepath.Join(modifiedDir, "chat-messages.json")
86+
require.NoError(t, os.WriteFile(modifiedChatPath, []byte(`[
87+
{"id":"user-1","variant":"user","content":"Modified message","timestamp":"03:04 PM"}
88+
]`), 0o644))
89+
90+
// Touch the file to ensure mtime changes.
91+
time.Sleep(10 * time.Millisecond)
92+
now := time.Now()
93+
require.NoError(t, os.Chtimes(modifiedChatPath, now, now))
94+
95+
// Third sync: only the modified session should be reparsed.
96+
synced = engine.SyncAll(context.Background(), nil).Synced
97+
assert.Equal(t, 1, synced, "third sync should only reparse the modified session")
98+
}

internal/sync/engine.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5317,13 +5317,35 @@ func (e *Engine) discoveredFileEffectiveMtime(
53175317
}
53185318
return mtime, nil
53195319
}
5320+
// Codebuff is excluded from the provider-Fingerprint path for
5321+
// cost: its Fingerprint content-hashes chat-messages.json plus
5322+
// run-state.json and chat-meta.json, so consulting it here would
5323+
// read every session's full transcript on each incremental sync,
5324+
// scaling cutoff filtering with the archive instead of the changed
5325+
// batch. The stat-only composite carries the same cutoff signal —
5326+
// the max mtime of all three files — so a companion-only change
5327+
// still looks fresh. Sources that pass the cutoff go on to the full
5328+
// fingerprint as usual.
5329+
if file.Agent == parser.AgentCodebuff {
5330+
info, err := os.Stat(file.Path)
5331+
if err != nil {
5332+
return 0, err
5333+
}
5334+
mtime := info.ModTime().UnixNano()
5335+
dir := filepath.Dir(file.Path)
5336+
for _, name := range []string{"run-state.json", "chat-meta.json"} {
5337+
companion := filepath.Join(dir, name)
5338+
if ci, err := os.Stat(companion); err == nil {
5339+
if ts := ci.ModTime().UnixNano(); ts > mtime {
5340+
mtime = ts
5341+
}
5342+
}
5343+
}
5344+
return mtime, nil
5345+
}
53205346
// Provider-authoritative sources resolve freshness through the provider
53215347
// Fingerprint so composite provider-owned source state participates in
5322-
// incremental-sync cutoff checks. Codebuff uses the provider-Fingerprint
5323-
// path because it is hash-sensitive (providerFingerprintHashInCacheKey)
5324-
// and the stat-only shortcut would miss same-size rewrites with preserved
5325-
// mtimes. The providerSourceFreshBeforeFingerprint check handles the
5326-
// stat-only optimization for unchanged sessions before the full fingerprint.
5348+
// incremental-sync cutoff checks.
53275349
if file.ProviderSource != nil && file.ProviderProcess {
53285350
if mtime, ok, err := e.providerSourceMtime(ctx, file); err != nil {
53295351
return 0, err

0 commit comments

Comments
 (0)