kenn-io--agentsview
f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
778 行
24 KiB
Go
778 行
24 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAutomationVerdictFromPrefix(t *testing.T) {
|
|
SetUserAutomationPrefixes([]string{"Custom automation:"})
|
|
t.Cleanup(func() { SetUserAutomationPrefixes(nil) })
|
|
|
|
tests := []struct {
|
|
name string
|
|
prefix string
|
|
fullByteLength int64
|
|
wantMatched bool
|
|
wantConclusive bool
|
|
}{
|
|
{
|
|
name: "built-in prefix", prefix: "You are a code reviewer. Review",
|
|
fullByteLength: 200, wantMatched: true, wantConclusive: true,
|
|
},
|
|
{
|
|
name: "user prefix", prefix: "Custom automation: inspect",
|
|
fullByteLength: 100, wantMatched: true, wantConclusive: true,
|
|
},
|
|
{
|
|
name: "substring visible", prefix: "This was invoked by roborev to perform this review",
|
|
fullByteLength: 100, wantMatched: true, wantConclusive: true,
|
|
},
|
|
{
|
|
name: "complete exact with Unicode whitespace", prefix: "\u2003Warmup\u00a0",
|
|
fullByteLength: int64(len("\u2003Warmup\u00a0")),
|
|
wantMatched: true, wantConclusive: true,
|
|
},
|
|
{
|
|
name: "exact followed by unseen suffix", prefix: "Warmup",
|
|
fullByteLength: int64(len("Warmup and continue")),
|
|
wantMatched: false, wantConclusive: false,
|
|
},
|
|
{
|
|
name: "complete non-match", prefix: "Explain this code",
|
|
fullByteLength: int64(len("Explain this code")),
|
|
wantMatched: false, wantConclusive: true,
|
|
},
|
|
{
|
|
name: "substring beyond prefix", prefix: strings.Repeat("x", 32),
|
|
fullByteLength: 200, wantMatched: false, wantConclusive: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
matched, conclusive := AutomationVerdictFromPrefix(
|
|
[]byte(tt.prefix), tt.fullByteLength,
|
|
)
|
|
assert.Equal(t, tt.wantMatched, matched)
|
|
assert.Equal(t, tt.wantConclusive, conclusive)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackfillIsAutomatedMatchingHashBoundsTextAllocations(t *testing.T) {
|
|
d := testDB(t)
|
|
const (
|
|
largeConclusiveBytes = 4 << 20
|
|
unresolvedBytes = 256 << 10
|
|
allocationCeiling = 6 << 20
|
|
)
|
|
|
|
prefixPrompt := "You are a code reviewer." +
|
|
strings.Repeat("x", largeConclusiveBytes)
|
|
insertSession(t, d, "prefix-first-message", "proj", func(s *Session) {
|
|
s.FirstMessage = &prefixPrompt
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
title := "Generated title"
|
|
insertSession(t, d, "prefix-first-user", "proj", func(s *Session) {
|
|
s.FirstMessage = &title
|
|
s.UserMessageCount = 1
|
|
})
|
|
require.NoError(t, d.ReplaceSessionMessages(
|
|
"prefix-first-user",
|
|
[]Message{userMsg("prefix-first-user", 0, prefixPrompt)},
|
|
), "store large prefix-matching first user message")
|
|
|
|
humanPrompt := strings.Repeat("h", unresolvedBytes)
|
|
insertSession(t, d, "large-human", "proj", func(s *Session) {
|
|
s.FirstMessage = &humanPrompt
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
lateSubstringPrompt := strings.Repeat("z", unresolvedBytes) +
|
|
" invoked by roborev to perform this review"
|
|
insertSession(t, d, "late-substring", "proj", func(s *Session) {
|
|
s.FirstMessage = &lateSubstringPrompt
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
largeMultiTurn := strings.Repeat("m", largeConclusiveBytes)
|
|
insertSession(t, d, "stale-multi-turn", "proj", func(s *Session) {
|
|
s.FirstMessage = &largeMultiTurn
|
|
s.UserMessageCount = 2
|
|
})
|
|
|
|
_, err := d.getWriter().Exec(`
|
|
UPDATE sessions
|
|
SET is_automated = CASE id
|
|
WHEN 'prefix-first-message' THEN 0
|
|
WHEN 'prefix-first-user' THEN 0
|
|
WHEN 'late-substring' THEN 0
|
|
WHEN 'stale-multi-turn' THEN 1
|
|
ELSE is_automated
|
|
END
|
|
WHERE id IN (
|
|
'prefix-first-message', 'prefix-first-user',
|
|
'late-substring', 'stale-multi-turn'
|
|
)`)
|
|
require.NoError(t, err, "seed stale automation flags")
|
|
_, err = d.getWriter().Exec(
|
|
`INSERT INTO stats (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
ClassifierHashKey, ClassifierHash(),
|
|
)
|
|
require.NoError(t, err, "stamp current classifier hash")
|
|
|
|
result := testing.Benchmark(func(b *testing.B) {
|
|
b.ReportAllocs()
|
|
for range b.N {
|
|
d.mu.Lock()
|
|
err := d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
})
|
|
t.Logf("matching-hash audit: %s, %d B/op", result, result.AllocedBytesPerOp())
|
|
assert.Less(t, result.AllocedBytesPerOp(), int64(allocationCeiling),
|
|
"matching-hash audit copied conclusive large prompts: %s", result.String())
|
|
|
|
for id, want := range map[string]bool{
|
|
"prefix-first-message": true,
|
|
"prefix-first-user": true,
|
|
"large-human": false,
|
|
"late-substring": true,
|
|
"stale-multi-turn": false,
|
|
} {
|
|
session, err := d.GetSession(context.Background(), id)
|
|
require.NoError(t, err, "get %s", id)
|
|
require.NotNil(t, session, "missing %s", id)
|
|
assert.Equal(t, want, session.IsAutomated, id)
|
|
}
|
|
}
|
|
|
|
func TestBackfillIsAutomatedBidirectional(t *testing.T) {
|
|
d := testDB(t)
|
|
|
|
// Seed a false negative: single-turn roborev session with
|
|
// is_automated = 0 (simulates pre-migration data).
|
|
insertSession(t, d, "missed", "proj", func(s *Session) {
|
|
fm := "You are a code reviewer. Review the code."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 3
|
|
s.UserMessageCount = 1
|
|
})
|
|
// Force is_automated to 0 to simulate pre-migration state.
|
|
_, err := d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'missed'",
|
|
)
|
|
require.NoError(t, err, "force missed to 0")
|
|
|
|
// Seed a stale false positive: multi-turn session that was
|
|
// previously marked automated under old broad rules.
|
|
insertSession(t, d, "stale", "proj", func(s *Session) {
|
|
fm := "# Fix Request for login flow"
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 10
|
|
s.UserMessageCount = 5
|
|
})
|
|
_, err = d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 1 WHERE id = 'stale'",
|
|
)
|
|
require.NoError(t, err, "force stale to 1")
|
|
|
|
// Clear the marker so the backfill will run.
|
|
_, err = d.getWriter().Exec(
|
|
"DELETE FROM stats WHERE key = ?",
|
|
ClassifierHashKey,
|
|
)
|
|
require.NoError(t, err, "clear marker")
|
|
|
|
// Run backfill.
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "first backfill run")
|
|
|
|
ctx := context.Background()
|
|
|
|
// False negative should now be set.
|
|
missed, err := d.GetSession(ctx, "missed")
|
|
require.NoError(t, err, "get missed")
|
|
assert.True(t, missed.IsAutomated, "missed session should be automated after backfill")
|
|
|
|
// Stale false positive should now be cleared.
|
|
stale, err := d.GetSession(ctx, "stale")
|
|
require.NoError(t, err, "get stale")
|
|
assert.False(t, stale.IsAutomated, "stale session should not be automated after backfill")
|
|
}
|
|
|
|
func TestBackfillIsAutomatedMarkerDoesNotHideCorruption(t *testing.T) {
|
|
d := testDB(t)
|
|
|
|
// Seed a roborev session.
|
|
insertSession(t, d, "review", "proj", func(s *Session) {
|
|
fm := "You are a code reviewer. Review the code."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 3
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
// Clear the marker and run backfill.
|
|
_, err := d.getWriter().Exec(
|
|
"DELETE FROM stats WHERE key = ?",
|
|
ClassifierHashKey,
|
|
)
|
|
require.NoError(t, err, "clear marker")
|
|
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "first run")
|
|
|
|
// Manually corrupt the session. Matching classifier hashes
|
|
// cannot be trusted as a complete integrity marker because
|
|
// other DB write paths can import or preserve stale flags.
|
|
_, err = d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'review'",
|
|
)
|
|
require.NoError(t, err, "corrupt")
|
|
|
|
// Second run should repair the inconsistent row even though
|
|
// the current hash is already stored.
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "second run")
|
|
|
|
ctx := context.Background()
|
|
review, err := d.GetSession(ctx, "review")
|
|
require.NoError(t, err, "get review")
|
|
assert.True(t, review.IsAutomated,
|
|
"second run should repair stale is_automated=0")
|
|
}
|
|
|
|
func TestBackfillIsAutomatedRepairsFalseNegativeWithMatchingHash(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "stale-hash", "proj", func(s *Session) {
|
|
fm := "You are combining multiple code review outputs into a single GitHub PR comment. Rules follow."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
_, err := d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'stale-hash'",
|
|
)
|
|
require.NoError(t, err, "force stale is_automated=0")
|
|
_, err = d.getWriter().Exec(
|
|
`INSERT INTO stats (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
ClassifierHashKey, ClassifierHash(),
|
|
)
|
|
require.NoError(t, err, "stamp current classifier hash")
|
|
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "backfill")
|
|
|
|
got, err := d.GetSession(ctx, "stale-hash")
|
|
require.NoError(t, err, "get stale-hash")
|
|
assert.True(t, got.IsAutomated,
|
|
"matching classifier hash must not hide stale is_automated=0")
|
|
}
|
|
|
|
func TestBackfillIsAutomatedUsesFirstUserMessageWhenFirstMessageIsTitle(
|
|
t *testing.T,
|
|
) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
title := "Generated review title"
|
|
insertSession(t, d, "title-review", "proj", func(s *Session) {
|
|
s.FirstMessage = &title
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
})
|
|
require.NoError(t, d.ReplaceSessionMessages("title-review", []Message{
|
|
userMsg("title-review", 0,
|
|
"You are a code reviewer. Review the code changes shown below."),
|
|
asstMsg("title-review", 1, "Review complete."),
|
|
}), "ReplaceSessionMessages")
|
|
_, err := d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'title-review'",
|
|
)
|
|
require.NoError(t, err, "force stale is_automated=0")
|
|
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "backfill")
|
|
|
|
got, err := d.GetSession(ctx, "title-review")
|
|
require.NoError(t, err, "get title-review")
|
|
require.NotNil(t, got, "title-review")
|
|
assert.True(t, got.IsAutomated,
|
|
"backfill should classify automation from the first stored user message")
|
|
}
|
|
|
|
func TestOpenRepairsAutomatedFalseNegativeWithMatchingHash(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
d, err := Open(path)
|
|
require.NoError(t, err, "open")
|
|
|
|
insertSession(t, d, "reload-stale", "proj", func(s *Session) {
|
|
fm := "You are combining multiple code review outputs into a single GitHub PR comment. Rules follow."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
})
|
|
_, err = d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'reload-stale'",
|
|
)
|
|
require.NoError(t, err, "force stale is_automated=0")
|
|
_, err = d.getWriter().Exec(
|
|
`INSERT INTO stats (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
ClassifierHashKey, ClassifierHash(),
|
|
)
|
|
require.NoError(t, err, "stamp current classifier hash")
|
|
require.NoError(t, d.Close(), "close")
|
|
|
|
reopened, err := Open(path)
|
|
require.NoError(t, err, "reopen")
|
|
defer reopened.Close()
|
|
|
|
got, err := reopened.GetSession(context.Background(), "reload-stale")
|
|
require.NoError(t, err, "get reload-stale")
|
|
assert.True(t, got.IsAutomated,
|
|
"Open must repair stale is_automated=0 despite matching hash")
|
|
}
|
|
|
|
// TestIncrementalUpdateReclassifiesOnPatternChange covers the
|
|
// case where the classifier gained a new pattern after a row
|
|
// was originally inserted. The row took the incremental path on
|
|
// subsequent parses, so UpsertSession never ran again to set
|
|
// is_automated. UpdateSessionIncremental must re-evaluate.
|
|
func TestIncrementalUpdateReclassifiesOnPatternChange(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Simulate a pre-existing single-turn session whose
|
|
// first_message now matches a classifier pattern but whose
|
|
// stored is_automated is stale at 0.
|
|
insertSession(t, d, "changelog-inc", "proj", func(s *Session) {
|
|
fm := "You are generating a changelog for myrepo version 1.0.0."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
})
|
|
_, err := d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'changelog-inc'",
|
|
)
|
|
require.NoError(t, err, "force stale is_automated=0")
|
|
|
|
// Incremental update with umc still <= 1.
|
|
err = callUpdateSessionIncrementalCompat(
|
|
t,
|
|
d,
|
|
"changelog-inc",
|
|
nil,
|
|
2,
|
|
1,
|
|
1024,
|
|
100,
|
|
0,
|
|
"",
|
|
0,
|
|
0,
|
|
false,
|
|
false,
|
|
)
|
|
require.NoError(t, err, "incremental update")
|
|
|
|
got, err := d.GetSession(ctx, "changelog-inc")
|
|
require.NoError(t, err, "get changelog-inc")
|
|
assert.True(t, got.IsAutomated,
|
|
"is_automated should be re-set after incremental update")
|
|
}
|
|
|
|
// TestIncrementalUpdateClearsWhenCountGrows covers the existing
|
|
// guard: when user_message_count grows past 1, is_automated must
|
|
// be cleared even if first_message still matches a pattern.
|
|
func TestIncrementalUpdateClearsWhenCountGrows(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "grew-past-one", "proj", func(s *Session) {
|
|
fm := "You are a code reviewer. Review the code."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 3
|
|
s.UserMessageCount = 1
|
|
})
|
|
// After UpsertSession the row is correctly is_automated=1.
|
|
pre, err := d.GetSession(ctx, "grew-past-one")
|
|
require.NoError(t, err, "get pre")
|
|
require.True(t, pre.IsAutomated,
|
|
"precondition: expected is_automated=1 after upsert")
|
|
|
|
// Incremental update pushes umc > 1 — must clear.
|
|
err = callUpdateSessionIncrementalCompat(
|
|
t,
|
|
d,
|
|
"grew-past-one",
|
|
nil,
|
|
7,
|
|
3,
|
|
2048,
|
|
200,
|
|
0,
|
|
"",
|
|
0,
|
|
0,
|
|
false,
|
|
false,
|
|
)
|
|
require.NoError(t, err, "incremental update")
|
|
|
|
got, err := d.GetSession(ctx, "grew-past-one")
|
|
require.NoError(t, err, "get grew-past-one")
|
|
assert.False(t, got.IsAutomated,
|
|
"is_automated should be cleared when umc grows > 1")
|
|
}
|
|
|
|
// TestIncrementalUpdateLeavesNonMatching covers a non-automated
|
|
// single-turn session: re-evaluation must NOT set is_automated
|
|
// when first_message doesn't match any pattern.
|
|
func TestIncrementalUpdateLeavesNonMatching(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "normal-single", "proj", func(s *Session) {
|
|
fm := "Fix the login bug please"
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
err := callUpdateSessionIncrementalCompat(
|
|
t,
|
|
d,
|
|
"normal-single",
|
|
nil,
|
|
2,
|
|
1,
|
|
1024,
|
|
100,
|
|
0,
|
|
"",
|
|
0,
|
|
0,
|
|
false,
|
|
false,
|
|
)
|
|
require.NoError(t, err, "incremental update")
|
|
|
|
got, err := d.GetSession(ctx, "normal-single")
|
|
require.NoError(t, err, "get normal-single")
|
|
assert.False(t, got.IsAutomated,
|
|
"is_automated should stay 0 for non-matching first_message")
|
|
}
|
|
|
|
// TestIncrementalUpdateClearsTerminationStatus verifies that an
|
|
// incremental sync resets termination_status to NULL. The classifier
|
|
// needs the full message slice to reach the right verdict, and the
|
|
// incremental path only sees the new tail. Leaving the previous
|
|
// classification in place would surface stale "tool_call_pending"
|
|
// or "awaiting_user" indicators in the UI for up to 15 minutes
|
|
// after the user appended a resolving result or a new prompt.
|
|
func TestIncrementalUpdateClearsTerminationStatus(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
insertSession(t, d, "stale-term", "proj", func(s *Session) {
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
v := "tool_call_pending"
|
|
s.TerminationStatus = &v
|
|
})
|
|
|
|
pre, err := d.GetSession(ctx, "stale-term")
|
|
require.NoError(t, err, "get pre")
|
|
require.NotNil(t, pre.TerminationStatus,
|
|
"precondition: expected tool_call_pending")
|
|
require.Equal(t, "tool_call_pending", *pre.TerminationStatus,
|
|
"precondition: expected tool_call_pending")
|
|
|
|
err = callUpdateSessionIncrementalCompat(
|
|
t,
|
|
d,
|
|
"stale-term",
|
|
nil,
|
|
4,
|
|
2,
|
|
2048,
|
|
200,
|
|
0,
|
|
"",
|
|
0,
|
|
0,
|
|
false,
|
|
false,
|
|
)
|
|
require.NoError(t, err, "incremental update")
|
|
|
|
got, err := d.GetSession(ctx, "stale-term")
|
|
require.NoError(t, err, "get stale-term")
|
|
assert.Nil(t, got.TerminationStatus,
|
|
"termination_status should be NULL after incremental update")
|
|
}
|
|
|
|
func TestBackfillIsAutomatedBumpsLocalModifiedAt(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Seed a single-turn roborev session that the new classifier
|
|
// will flip to is_automated = 1.
|
|
insertSession(t, d, "to-flip", "proj", func(s *Session) {
|
|
fm := "You are a code reviewer. Review the code."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 3
|
|
s.UserMessageCount = 1
|
|
})
|
|
// Force is_automated = 0 so the backfill has work to do.
|
|
_, err := d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'to-flip'",
|
|
)
|
|
require.NoError(t, err, "force to-flip to 0")
|
|
|
|
// Snapshot local_modified_at before the backfill.
|
|
before, err := d.GetSessionFull(ctx, "to-flip")
|
|
require.NoError(t, err, "get to-flip before")
|
|
var beforeLM string
|
|
if before.LocalModifiedAt != nil {
|
|
beforeLM = *before.LocalModifiedAt
|
|
}
|
|
|
|
// SQLite's strftime('now') ticks at millisecond precision.
|
|
// Sleep a few ms so a re-set produces a strictly later value.
|
|
// (Mirrors internal/db/signals_test.go:164.)
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
// Clear the marker so the backfill runs.
|
|
_, err = d.getWriter().Exec(
|
|
"DELETE FROM stats WHERE key = ?",
|
|
ClassifierHashKey,
|
|
)
|
|
require.NoError(t, err, "clear marker")
|
|
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "backfill run")
|
|
|
|
after, err := d.GetSessionFull(ctx, "to-flip")
|
|
require.NoError(t, err, "get to-flip after")
|
|
require.True(t, after.IsAutomated, "to-flip should be automated after backfill")
|
|
require.NotNil(t, after.LocalModifiedAt,
|
|
"local_modified_at not set after backfill")
|
|
require.NotEmpty(t, *after.LocalModifiedAt,
|
|
"local_modified_at not set after backfill")
|
|
assert.Greater(t, *after.LocalModifiedAt, beforeLM,
|
|
"local_modified_at not bumped")
|
|
}
|
|
|
|
// TestBackfillIsAutomatedRerunsOnHashChange verifies that a
|
|
// classifier change (here, adding a user prefix) invalidates
|
|
// the stored hash and re-runs the backfill on next open,
|
|
// without any manual marker bump.
|
|
func TestBackfillIsAutomatedRerunsOnHashChange(t *testing.T) {
|
|
t.Cleanup(func() {
|
|
SetUserAutomationPrefixes(nil)
|
|
SetUserAutomationSubstrings(nil)
|
|
SetUserAutomationExactMatches(nil)
|
|
})
|
|
d := testDB(t)
|
|
|
|
// Seed a session whose first_message would match a
|
|
// user prefix once added. With the empty user-prefix
|
|
// list it should be is_automated=0.
|
|
insertSession(t, d, "essay", "proj", func(s *Session) {
|
|
fm := "You are analyzing an essay about epistemology."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 2
|
|
s.UserMessageCount = 1
|
|
})
|
|
ctx := context.Background()
|
|
pre, err := d.GetSession(ctx, "essay")
|
|
require.NoError(t, err, "get essay before")
|
|
require.False(t, pre.IsAutomated,
|
|
"precondition: essay should be is_automated=0")
|
|
|
|
// Add a user prefix and re-run backfill. The new hash
|
|
// should not equal the stored hash, so the backfill
|
|
// runs and flips is_automated to 1.
|
|
SetUserAutomationPrefixes([]string{"You are analyzing an essay"})
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "backfill after prefix add")
|
|
|
|
got, err := d.GetSession(ctx, "essay")
|
|
require.NoError(t, err, "get essay after")
|
|
assert.True(t, got.IsAutomated,
|
|
"essay should be is_automated=1 after user prefix added")
|
|
|
|
// A second backfill (no further classifier change) still
|
|
// repairs inconsistent rows; the stored hash is not a
|
|
// substitute for row integrity.
|
|
_, err = d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'essay'",
|
|
)
|
|
require.NoError(t, err, "force back to 0")
|
|
d.mu.Lock()
|
|
err = d.backfillIsAutomatedLocked(d.getWriter())
|
|
d.mu.Unlock()
|
|
require.NoError(t, err, "second backfill")
|
|
got, err = d.GetSession(ctx, "essay")
|
|
require.NoError(t, err, "get essay second")
|
|
assert.True(t, got.IsAutomated,
|
|
"second backfill must repair stale flag when hash unchanged")
|
|
}
|
|
|
|
// TestBackfillFixesOrphanCopyClassificationGap reproduces
|
|
// the production bug where ResyncAll's orphan-copied rows kept
|
|
// stale is_automated values forever. The sequence:
|
|
//
|
|
// 1. Old DB has a single-turn roborev session marked
|
|
// is_automated=0 (e.g. it predates a classifier change, or
|
|
// was synced from a remote with a stale binary).
|
|
// 2. ResyncAll opens an empty temp DB. Its at-Open backfill
|
|
// runs on zero rows and stamps the *current* classifier
|
|
// hash, so future Opens skip backfill.
|
|
// 3. CopyOrphanedDataFrom imports the orphan row verbatim,
|
|
// including is_automated=0.
|
|
// 4. The stamped hash still matches. A regular backfill must
|
|
// nevertheless audit rows and repair the imported flag.
|
|
//
|
|
// This guards against treating the classifier hash as a complete
|
|
// integrity marker.
|
|
func TestBackfillFixesOrphanCopyClassificationGap(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// 1. Old DB with a misclassified single-turn roborev session.
|
|
srcPath := filepath.Join(dir, "old.db")
|
|
srcDB, err := Open(srcPath)
|
|
require.NoError(t, err, "Open src")
|
|
insertSession(t, srcDB, "stale-orphan", "proj", func(s *Session) {
|
|
fm := "You are a code reviewer. Review the code."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 3
|
|
s.UserMessageCount = 1
|
|
})
|
|
_, err = srcDB.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'stale-orphan'",
|
|
)
|
|
require.NoError(t, err, "force orphan to is_automated=0")
|
|
srcDB.Close()
|
|
|
|
// 2. Fresh dst DB (Open's at-Open backfill ran on an empty
|
|
// table and stamped the current classifier hash).
|
|
dstPath := filepath.Join(dir, "new.db")
|
|
dstDB, err := Open(dstPath)
|
|
require.NoError(t, err, "Open dst")
|
|
defer dstDB.Close()
|
|
|
|
// 3. Copy orphan rows (mirrors ResyncAll line ~954).
|
|
count, err := dstDB.CopyOrphanedDataFrom(srcPath)
|
|
require.NoError(t, err, "CopyOrphanedDataFrom")
|
|
require.Equal(t, 1, count, "expected 1 orphan")
|
|
|
|
ctx := context.Background()
|
|
got, err := dstDB.GetSession(ctx, "stale-orphan")
|
|
require.NoError(t, err, "get orphan after copy")
|
|
require.False(t, got.IsAutomated,
|
|
"precondition: orphan row should carry stale is_automated=0")
|
|
|
|
// 4. A regular backfill must still repair the row even
|
|
// though the stored classifier hash already matches.
|
|
dstDB.mu.Lock()
|
|
err = dstDB.backfillIsAutomatedLocked(dstDB.getWriter())
|
|
dstDB.mu.Unlock()
|
|
require.NoError(t, err, "backfill")
|
|
got, err = dstDB.GetSession(ctx, "stale-orphan")
|
|
require.NoError(t, err, "get orphan after backfill")
|
|
assert.True(t, got.IsAutomated,
|
|
"backfill must reclassify orphan-copied rows so they don't keep stale is_automated values")
|
|
}
|
|
|
|
// TestForceBackfillIsAutomatedRunsDespiteMatchingHash verifies
|
|
// that ForceBackfillIsAutomated reclassifies even when the
|
|
// stored hash matches the current classifier hash. This is the
|
|
// safety net ResyncAll relies on after CopyOrphanedDataFrom
|
|
// imports rows whose is_automated values were computed against
|
|
// the old DB but are now stamped under the temp DB's hash.
|
|
func TestForceBackfillIsAutomatedRunsDespiteMatchingHash(t *testing.T) {
|
|
d := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
// Seed a single-turn roborev session that should be
|
|
// is_automated = 1 under the current classifier.
|
|
insertSession(t, d, "stuck", "proj", func(s *Session) {
|
|
fm := "You are a code reviewer. Review the code."
|
|
s.FirstMessage = &fm
|
|
s.MessageCount = 3
|
|
s.UserMessageCount = 1
|
|
})
|
|
|
|
// Force is_automated = 0 to simulate a row imported via
|
|
// CopyOrphanedDataFrom from a DB whose classifier set was
|
|
// stale at the time the flag was computed.
|
|
_, err := d.getWriter().Exec(
|
|
"UPDATE sessions SET is_automated = 0 WHERE id = 'stuck'",
|
|
)
|
|
require.NoError(t, err, "force stuck to 0")
|
|
|
|
// Stamp the current hash so a *plain* backfill would
|
|
// short-circuit (mirrors ResyncAll's temp DB state after
|
|
// at-Open backfill ran on an empty table).
|
|
_, err = d.getWriter().Exec(
|
|
`INSERT INTO stats (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
ClassifierHashKey, ClassifierHash(),
|
|
)
|
|
require.NoError(t, err, "stamp current hash")
|
|
|
|
// The force path must reclassify regardless of the stored
|
|
// hash and re-stamp the current classifier hash.
|
|
require.NoError(t, d.ForceBackfillIsAutomated(), "force backfill")
|
|
|
|
got, err := d.GetSession(ctx, "stuck")
|
|
require.NoError(t, err, "get stuck after force")
|
|
assert.True(t, got.IsAutomated,
|
|
"ForceBackfillIsAutomated must flip stuck to is_automated=1")
|
|
|
|
// And the hash must be re-stamped after the force run so
|
|
// subsequent Opens don't re-do the work.
|
|
var stored string
|
|
err = d.getWriter().QueryRow(
|
|
`SELECT value FROM stats WHERE key = ?`,
|
|
ClassifierHashKey,
|
|
).Scan(&stored)
|
|
require.NoError(t, err, "read hash after force")
|
|
assert.Equal(t, ClassifierHash(), stored,
|
|
"stored hash not refreshed after force")
|
|
}
|