diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f395b8100..df120d79d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,8 +164,24 @@ jobs: - name: Restore pricing snapshot run: go run ./internal/pricing/cmd/litellm-snapshot -restore - - name: Run Go tests - run: go test -tags "fts5" ${{ runner.os == 'Linux' && '-coverprofile=coverage.out' || '' }} ./... -v -count=1 -timeout=20m + - name: Run Go tests (Linux) + if: runner.os == 'Linux' + run: go test -tags "fts5" -coverprofile=coverage.out ./... -v -count=1 -timeout=20m + env: + CGO_ENABLED: "1" + + - name: Run Go tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + # DuckDB runs in its own job below so its CGo-heavy package does not + # occupy one of this four-core runner's package workers for minutes. + $packages = @(go list ./...) + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + $packages = $packages | Where-Object { $_ -ne 'go.kenn.io/agentsview/internal/duckdb' } + go test -tags "fts5" $packages -count=1 -timeout=20m env: CGO_ENABLED: "1" @@ -185,6 +201,47 @@ jobs: if: runner.os == 'Linux' && steps.codecov.outcome == 'failure' run: echo "::warning::Codecov upload failed" + # Keep the derived-mirror suite concurrent with the main Windows package set. + test-duckdb-windows: + name: Go Test (windows-latest, DuckDB) + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + + - name: Setup MinGW + id: setup_mingw + continue-on-error: true + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + with: + msystem: MINGW64 + update: false + install: mingw-w64-x86_64-gcc + path-type: inherit + + - name: Setup MinGW (retry on transient failure) + if: steps.setup_mingw.outcome == 'failure' + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2 + with: + msystem: MINGW64 + update: false + install: mingw-w64-x86_64-gcc + path-type: inherit + + - name: Restore pricing snapshot + run: go run ./internal/pricing/cmd/litellm-snapshot -restore + + - name: Run DuckDB tests + run: go test -tags "fts5" ./internal/duckdb -count=1 -timeout=20m + env: + CGO_ENABLED: "1" + test-race: name: Go Test (race detector) runs-on: ${{ github.repository == 'kenn-io/agentsview' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }} diff --git a/internal/artifact/export_test.go b/internal/artifact/export_test.go index e7278fcdc..a36e21ae1 100644 --- a/internal/artifact/export_test.go +++ b/internal/artifact/export_test.go @@ -2,6 +2,7 @@ package artifact import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -45,6 +46,38 @@ func testExportDB(t *testing.T) *db.DB { return database } +// seedBareExportSessions inserts the minimal rows these export cardinality +// tests need in one transaction. The real sessions-table export triggers still +// enqueue every local row; parser validation and message replacement are not +// part of the behavior under test here. +func seedBareExportSessions( + t *testing.T, + database *db.DB, + count int, + idFormat string, + machine string, +) { + t.Helper() + ctx := t.Context() + require.NoError(t, database.Update(func(tx *sql.Tx) error { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO sessions + (id, project, machine, agent, created_at) + VALUES (?, 'project', ?, 'claude', '2026-06-14T01:02:03Z')`) + if err != nil { + return err + } + defer func() { _ = stmt.Close() }() + for i := range count { + if _, err := stmt.ExecContext( + ctx, fmt.Sprintf(idFormat, i), machine, + ); err != nil { + return err + } + } + return nil + }), "seed bare export sessions") +} + // latestStoreCheckpointForTest locates the highest-sequence checkpoint for // origin and decodes its full session map. It stands in for peer.go's // latestStoreCheckpointSummary (out of PR2 scope; see plan hazard 6) so @@ -1364,12 +1397,9 @@ func TestArtifactExportCardinalityLoadsOnlyDirtyBatch(t *testing.T) { for _, archiveSize := range []int{20, 2000} { t.Run(fmt.Sprintf("archive-%d", archiveSize), func(t *testing.T) { database := testExportDB(t) - for i := range archiveSize { - require.NoError(t, database.UpsertSession(db.Session{ - ID: fmt.Sprintf("peer-%04d", i), Project: "project", - Machine: "peer-a1b2c3", Agent: "claude", - })) - } + seedBareExportSessions( + t, database, archiveSize, "peer-%04d", "peer-a1b2c3", + ) require.NoError(t, database.UpsertSession(db.Session{ ID: "dirty", Project: "project", Machine: "local", Agent: "claude", })) @@ -1404,12 +1434,9 @@ func TestExportToStoreCardinalityIgnoresUnrelatedArchiveBodies(t *testing.T) { for _, archiveSize := range []int{20, 2000} { t.Run(fmt.Sprintf("archive-%d", archiveSize), func(t *testing.T) { database := testExportDB(t) - for i := range archiveSize { - require.NoError(t, database.UpsertSession(db.Session{ - ID: fmt.Sprintf("peer-%04d", i), Project: "project", - Machine: "peer-a1b2c3", Agent: "claude", - })) - } + seedBareExportSessions( + t, database, archiveSize, "peer-%04d", "peer-a1b2c3", + ) seedSession(t, database, "dirty", "project") counted := &countingCanonicalExportDB{DB: database} filesystem, err := newProtocolTestStore(t.TempDir()) @@ -1473,12 +1500,9 @@ func TestExportToStoreIncrementalBatchIsBoundedAndFullStreamsAllBodies(t *testin func TestExportToStoreFullDrainsMoreThanOneClaimPage(t *testing.T) { database := testExportDB(t) const total = 1025 - for i := range total { - require.NoError(t, database.UpsertSession(db.Session{ - ID: fmt.Sprintf("session-%04d", i), Project: "project", - Machine: "local", Agent: "claude", CreatedAt: "2026-06-14T01:02:03Z", - })) - } + seedBareExportSessions( + t, database, total, "session-%04d", "local", + ) counted := &countingCanonicalExportDB{DB: database} filesystem, err := newProtocolTestStore(t.TempDir()) require.NoError(t, err) @@ -1512,12 +1536,7 @@ func TestExportToStoreFullDrainsMoreThanOneClaimPage(t *testing.T) { func TestExportToStoreExplicitSessionIDsClaimBeyondOldestQueuePage(t *testing.T) { database := testExportDB(t) const total = 1025 - for i := range total { - require.NoError(t, database.UpsertSession(db.Session{ - ID: fmt.Sprintf("session-%04d", i), Project: "project", - Machine: "local", Agent: "claude", - })) - } + seedBareExportSessions(t, database, total, "session-%04d", "local") filesystem, err := newProtocolTestStore(t.TempDir()) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, filesystem.Close()) }) diff --git a/internal/db/session_stats_race_test.go b/internal/db/session_stats_race_test.go new file mode 100644 index 000000000..54d01e836 --- /dev/null +++ b/internal/db/session_stats_race_test.go @@ -0,0 +1,61 @@ +//go:build race + +package db + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestOutcomeStatsWriterCloseRaceDoesNotPanic exercises the maintenance +// writer transition under the race detector. The normal suite covers the +// closed-writer state deterministically without paying for hundreds of git +// scans on every platform. +func TestOutcomeStatsWriterCloseRaceDoesNotPanic(t *testing.T) { + skipIfNoGit(t) + d := testDB(t) + repo := statsOutcomeRepo(t) + insertSessionFixture(t, d, sessionFixture{ + id: "writer-race", agent: "claude", userMsgs: 5, + startedAt: hoursAgo(5), cwd: repo, + }) + + done := make(chan struct{}) + toggleErr := make(chan error, 1) + var wg sync.WaitGroup + wg.Go(func() { + for { + select { + case <-done: + return + default: + } + if err := d.CloseWriter(); err != nil { + toggleErr <- err + return + } + if err := d.ReopenWriter(); err != nil { + toggleErr <- err + return + } + } + }) + + for range 300 { + // The race detector validates synchronization; transient errors while + // the writer is closed are acceptable for this stress case. + _, _ = d.GetSessionStats(context.Background(), StatsFilter{ + Since: "28d", IncludeGitOutcomes: true, + }) + } + close(done) + wg.Wait() + select { + case err := <-toggleErr: + require.NoError(t, err, "writer toggling failed") + default: + } +} diff --git a/internal/db/session_stats_test.go b/internal/db/session_stats_test.go index f29598a87..68b1ae932 100644 --- a/internal/db/session_stats_test.go +++ b/internal/db/session_stats_test.go @@ -2612,54 +2612,28 @@ func TestGetSessionStats_OutcomeStats_Happy(t *testing.T) { assert.Nil(t, out.PRsMerged, "PRsMerged want nil (no GHToken)") } -// TestOutcomeStatsWriterCloseRaceDoesNotPanic guards the writer snapshot in -// computeOutcomeStats: closing the writer for a maintenance pass concurrently -// with the git outcome-stats path must fall back to the read-only cache and -// never hand git.NewCache a nil writer pool (which would panic on first use). -func TestOutcomeStatsWriterCloseRaceDoesNotPanic(t *testing.T) { +// TestOutcomeStatsClosedWriterUsesReadOnlyCache guards the writer snapshot in +// computeOutcomeStats: when a maintenance pass has closed the writer, the git +// outcome-stats path must use the read-only cache and return the same result. +// The concurrent close/reopen stress case lives in session_stats_race_test.go. +func TestOutcomeStatsClosedWriterUsesReadOnlyCache(t *testing.T) { skipIfNoGit(t) d := testDB(t) ctx := context.Background() repo := statsOutcomeRepo(t) insertSessionFixture(t, d, sessionFixture{ - id: "race1", agent: "claude", userMsgs: 5, + id: "closed-writer", agent: "claude", userMsgs: 5, startedAt: hoursAgo(5), cwd: repo, }) - done := make(chan struct{}) - toggleErr := make(chan error, 1) - var wg sync.WaitGroup - wg.Go(func() { - for { - select { - case <-done: - return - default: - } - if err := d.CloseWriter(); err != nil { - toggleErr <- err - return - } - if err := d.ReopenWriter(); err != nil { - toggleErr <- err - return - } - } + require.NoError(t, d.CloseWriter(), "close writer") + stats, err := d.GetSessionStats(ctx, StatsFilter{ + Since: "28d", IncludeGitOutcomes: true, }) - - for range 300 { - // Must never panic; a transient error while the writer is closed is fine. - _, _ = d.GetSessionStats(ctx, StatsFilter{ - Since: "28d", IncludeGitOutcomes: true, - }) - } - close(done) - wg.Wait() - select { - case err := <-toggleErr: - require.NoError(t, err, "writer toggling failed") - default: - } + require.NoError(t, err, "GetSessionStats with closed writer") + require.NotNil(t, stats.OutcomeStats, "OutcomeStats") + assert.Equal(t, 1, stats.OutcomeStats.ReposActive, "ReposActive") + assert.Equal(t, 3, stats.OutcomeStats.Commits, "Commits") } // TestGetSessionStats_OutcomeStats_NoCwd verifies that sessions without diff --git a/internal/parser/hermes_provider_test.go b/internal/parser/hermes_provider_test.go index 6eb50844b..9a47b8ced 100644 --- a/internal/parser/hermes_provider_test.go +++ b/internal/parser/hermes_provider_test.go @@ -842,19 +842,23 @@ func TestHermesMemberCoreSeedRetainedIDBytesStayBounded(t *testing.T) { stateDB := filepath.Join(root, "state.db") conn, err := sql.Open("sqlite3", stateDB) require.NoError(t, err) - _, err = conn.Exec("DELETE FROM messages; DELETE FROM sessions") + tx, err := conn.Begin() + require.NoError(t, err) + defer func() { _ = tx.Rollback() }() + _, err = tx.Exec("DELETE FROM messages; DELETE FROM sessions") require.NoError(t, err) for i := range sessionCount { id := fmt.Sprintf("member-%06d", i) - _, err = conn.Exec(`INSERT INTO sessions + _, err = tx.Exec(`INSERT INTO sessions (id, source, started_at, estimated_cost_usd, actual_cost_usd) VALUES (?, 'cli', ?, 0, 0)`, id, i) require.NoError(t, err) - _, err = conn.Exec(`INSERT INTO messages + _, err = tx.Exec(`INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', 'hello', ?)`, id, i) require.NoError(t, err) } + require.NoError(t, tx.Commit()) require.NoError(t, conn.Close()) var retained, peak int64 diff --git a/internal/parser/opencode_provider_test.go b/internal/parser/opencode_provider_test.go index a5b2ede33..ef2d0e4bb 100644 --- a/internal/parser/opencode_provider_test.go +++ b/internal/parser/opencode_provider_test.go @@ -1386,13 +1386,15 @@ func TestOpenCodeChangedPathWatermarkMergeMaterializesOnlyChangedBatch( seeder.AddProject("proj", "/home/user/app") const base = int64(1779012000000) var stored []StoredMemberFreshness - for i := range sessions { - id := fmt.Sprintf("ses-%06d", i) - seeder.AddSession(id, "proj", "", id, base, base) - stored = append(stored, StoredMemberFreshness{ - Path: dbPath + "#" + id, CoveredThroughNS: base * 1_000_000, - }) - } + seeder.InTransaction(func(seeder *OpenCodeSeeder) { + for i := range sessions { + id := fmt.Sprintf("ses-%06d", i) + seeder.AddSession(id, "proj", "", id, base, base) + stored = append(stored, StoredMemberFreshness{ + Path: dbPath + "#" + id, CoveredThroughNS: base * 1_000_000, + }) + } + }) // One session advances past its stored coverage. changed := fmt.Sprintf("ses-%06d", sessions/2) _, err := seeder.db.ExecContext(t.Context(), diff --git a/internal/parser/opencode_test.go b/internal/parser/opencode_test.go index ce31f9523..dd8a14fd3 100644 --- a/internal/parser/opencode_test.go +++ b/internal/parser/opencode_test.go @@ -91,14 +91,36 @@ func assertEq[T comparable](t *testing.T, name string, got, want T) { assert.Equal(t, want, got, name) } +type openCodeSeedExecer interface { + Exec(query string, args ...any) (sql.Result, error) +} + type OpenCodeSeeder struct { - db *sql.DB - t *testing.T + db *sql.DB + exec openCodeSeedExecer + t *testing.T +} + +func (s *OpenCodeSeeder) executor() openCodeSeedExecer { + if s.exec != nil { + return s.exec + } + return s.db +} + +func (s *OpenCodeSeeder) InTransaction(seed func(*OpenCodeSeeder)) { + s.t.Helper() + tx, err := s.db.Begin() + require.NoError(s.t, err, "begin seed transaction") + defer func() { _ = tx.Rollback() }() + + seed(&OpenCodeSeeder{db: s.db, exec: tx, t: s.t}) + require.NoError(s.t, tx.Commit(), "commit seed transaction") } func (s *OpenCodeSeeder) AddProject(id, worktree string) { s.t.Helper() - _, err := s.db.Exec(`INSERT INTO project (id, worktree) VALUES (?, ?)`, id, worktree) + _, err := s.executor().Exec(`INSERT INTO project (id, worktree) VALUES (?, ?)`, id, worktree) require.NoError(s.t, err, "add project") } @@ -115,7 +137,7 @@ func (s *OpenCodeSeeder) AddSession(id, projectID, parentID, title string, timeC // Omit directory so the same helper works on legacy schemas that // lack the column; modern fixtures default directory to ''. - _, err := s.db.Exec( + _, err := s.executor().Exec( `INSERT INTO session (id, project_id, parent_id, title, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)`, @@ -138,7 +160,7 @@ func (s *OpenCodeSeeder) AddSessionDirectory( tStr = title } - _, err := s.db.Exec( + _, err := s.executor().Exec( `INSERT INTO session (id, project_id, parent_id, title, directory, time_created, time_updated) @@ -150,14 +172,14 @@ func (s *OpenCodeSeeder) AddSessionDirectory( func (s *OpenCodeSeeder) AddMessage(id, sessionID string, timeCreated, timeUpdated int64, data string) { s.t.Helper() - _, err := s.db.Exec(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`, + _, err := s.executor().Exec(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`, id, sessionID, timeCreated, timeUpdated, data) require.NoError(s.t, err, "add message") } func (s *OpenCodeSeeder) AddPart(id, messageID, sessionID string, timeCreated, timeUpdated int64, data string) { s.t.Helper() - _, err := s.db.Exec(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, + _, err := s.executor().Exec(`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, id, messageID, sessionID, timeCreated, timeUpdated, data) require.NoError(s.t, err, "add part") } diff --git a/internal/sync/aider_reconciliation_test.go b/internal/sync/aider_reconciliation_test.go index 79f10e7fd..ad0626cf0 100644 --- a/internal/sync/aider_reconciliation_test.go +++ b/internal/sync/aider_reconciliation_test.go @@ -60,7 +60,10 @@ func TestReconcileWatchRootsAiderScansOneLargeContainerOnce(t *testing.T) { repo := filepath.Join(root, "repo") require.NoError(t, os.MkdirAll(repo, 0o755)) var history strings.Builder - for i := range 600 { + // Cross the reconciliation page boundary so the fixture exercises a + // multi-page container without paying to archive hundreds of redundant + // rows beyond the boundary. + for i := range reconciliationPageSize + 1 { history.WriteString("# aider chat started at 2026-06-09 14:01:00\n") history.WriteString("#### prompt ") history.WriteString(strings.Repeat("x", i%17+1)) diff --git a/internal/sync/opencode_container_perf_test.go b/internal/sync/opencode_container_perf_test.go index 261dfd67f..2993060a0 100644 --- a/internal/sync/opencode_container_perf_test.go +++ b/internal/sync/opencode_container_perf_test.go @@ -34,13 +34,15 @@ func TestOpenCodeSharedContainerChangeIsPerSessionBounded(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) oc := createOpenCodeDB(t, env.opencodeDir) oc.addProject(t, "proj", "/home/user/code/app") - for i := range n { - seedOpenCodeSQLiteTextSession( - t, oc, "proj", fmt.Sprintf("ses%05d", i), - 1779012000000, 1779012030000, - "prompt", "answer", - ) - } + oc.inTransaction(t, func(oc *openCodeTestDB) { + for i := range n { + seedOpenCodeSQLiteTextSession( + t, oc, "proj", fmt.Sprintf("ses%05d", i), + 1779012000000, 1779012030000, + "prompt", "answer", + ) + } + }) require.Equal(t, n, env.engine.SyncAll(context.Background(), nil).Synced) @@ -88,13 +90,15 @@ func TestOpenCodeWatcherEventIsWatermarkBounded(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) oc := createOpenCodeDB(t, env.opencodeDir) oc.addProject(t, "proj", "/home/user/code/app") - for i := range n { - seedOpenCodeSQLiteTextSession( - t, oc, "proj", fmt.Sprintf("ses%05d", i), - 1779012000000, 1779012030000, - "prompt", "answer", - ) - } + oc.inTransaction(t, func(oc *openCodeTestDB) { + for i := range n { + seedOpenCodeSQLiteTextSession( + t, oc, "proj", fmt.Sprintf("ses%05d", i), + 1779012000000, 1779012030000, + "prompt", "answer", + ) + } + }) require.Equal(t, n, env.engine.SyncAll(context.Background(), nil).Synced) diff --git a/internal/sync/test_helpers_test.go b/internal/sync/test_helpers_test.go index fa9067b0f..cf167cb53 100644 --- a/internal/sync/test_helpers_test.go +++ b/internal/sync/test_helpers_test.go @@ -164,9 +164,14 @@ func (e *testEnv) updateSessionProject( } // openCodeTestDB manages an OpenCode SQLite database for tests. +type openCodeTestExecer interface { + Exec(query string, args ...any) (sql.Result, error) +} + type openCodeTestDB struct { path string db *sql.DB + exec openCodeTestExecer } type kiroSQLiteTestDB struct { @@ -433,10 +438,27 @@ func writeLegacyKiroSession( func (oc *openCodeTestDB) mustExec(t *testing.T, msg, query string, args ...any) { t.Helper() - _, err := oc.db.Exec(query, args...) + executor := oc.exec + if executor == nil { + executor = oc.db + } + _, err := executor.Exec(query, args...) require.NoError(t, err, msg) } +func (oc *openCodeTestDB) inTransaction( + t *testing.T, + seed func(*openCodeTestDB), +) { + t.Helper() + tx, err := oc.db.Begin() + require.NoError(t, err, "begin OpenCode seed transaction") + defer func() { _ = tx.Rollback() }() + + seed(&openCodeTestDB{path: oc.path, db: oc.db, exec: tx}) + require.NoError(t, tx.Commit(), "commit OpenCode seed transaction") +} + func (oc *openCodeTestDB) addProject( t *testing.T, id, worktree string, ) {