Skip to content

Commit d694604

Browse files
committed
fix(devin): verify hashed skip-cache hits
Hash-suffixed provider skip-cache entries can outlive the DB row they originally matched. When a Devin transcript is rewritten A -> B -> A with the same mtime, the old A key must not skip while the DB still stores hash B. Check the current Devin fingerprint hash against the stored DB hash before honoring a provider cache hit. Non-hash-required providers keep the prior mtime-only skip behavior. VALID (fixed): kenn-io#1
1 parent 7c29acb commit d694604

2 files changed

Lines changed: 108 additions & 1 deletion

File tree

internal/sync/engine.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3692,7 +3692,9 @@ func (e *Engine) processProviderFile(
36923692
// self-healing (e.g. a parser data-version bump or generated
36933693
// roborev CI worktree project): clear the entry and fall through
36943694
// to a full reparse, mirroring the legacy process arm.
3695-
if e.pathNeedsCachedSkipBypass(file.Path) {
3695+
if !e.providerSkipCacheEntryFreshInDB(file, source, fingerprint) {
3696+
e.clearSkip(cacheKey)
3697+
} else if e.pathNeedsCachedSkipBypass(file.Path) {
36963698
e.clearSkip(cacheKey)
36973699
} else {
36983700
return processResult{
@@ -4152,6 +4154,25 @@ func providerFingerprintHashRequiredForFreshness(agent parser.AgentType) bool {
41524154
return agent == parser.AgentDevin
41534155
}
41544156

4157+
func (e *Engine) providerSkipCacheEntryFreshInDB(
4158+
file parser.DiscoveredFile,
4159+
source parser.SourceRef,
4160+
fingerprint parser.SourceFingerprint,
4161+
) bool {
4162+
agent := file.Agent
4163+
if agent == "" {
4164+
agent = source.Provider
4165+
}
4166+
if fingerprint.Hash == "" || !providerFingerprintHashRequiredForFreshness(agent) {
4167+
return true
4168+
}
4169+
lookupPath := providerSkipLookupPath(file, source, fingerprint)
4170+
if e.pathRewriter != nil {
4171+
lookupPath = e.pathRewriter(lookupPath)
4172+
}
4173+
return e.providerFingerprintHashMatchesDB(agent, lookupPath, fingerprint)
4174+
}
4175+
41554176
func processFileUsesProvider(agent parser.AgentType) bool {
41564177
switch agent {
41574178
case parser.AgentForge, parser.AgentPiebald, parser.AgentWarp:

internal/sync/provider_process_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,71 @@ func TestProcessFileProviderDevinSameSizeSameMtimeTranscriptRewriteReparses(t *t
764764
}
765765
}
766766

767+
func TestProcessFileProviderDevinRepeatedHashRewriteIgnoresStaleHashedCache(t *testing.T) {
768+
root := t.TempDir()
769+
dbPath, transcriptPath := writeProcessProviderDevinFixture(
770+
t,
771+
root,
772+
"session-repeated-hash",
773+
"Initial reply",
774+
1700000000000,
775+
1700000005000,
776+
)
777+
virtualPath := parser.VirtualSourcePath(dbPath, "session-repeated-hash")
778+
file := parser.DiscoveredFile{
779+
Path: virtualPath,
780+
Agent: parser.AgentDevin,
781+
}
782+
database := dbtest.OpenTestDB(t)
783+
engine := NewEngine(database, EngineConfig{
784+
AgentDirs: map[parser.AgentType][]string{
785+
parser.AgentDevin: {root},
786+
},
787+
Machine: "devbox",
788+
})
789+
790+
first := engine.processFile(context.Background(), file)
791+
require.NoError(t, first.err)
792+
require.Len(t, first.results, 1)
793+
require.Len(t, first.results[0].Messages, 2)
794+
assert.Equal(t, "Initial reply", first.results[0].Messages[1].Content)
795+
initialMtime := first.results[0].Session.File.Mtime
796+
initialHash := first.results[0].Session.File.Hash
797+
require.NotZero(t, initialMtime)
798+
require.NotEmpty(t, initialHash)
799+
require.Contains(t, first.cacheKey, "?source_hash="+initialHash)
800+
writeProcessProviderDevinResult(t, engine, first)
801+
802+
engine.cacheSkip(first.cacheKey, initialMtime)
803+
804+
writeProcessProviderDevinTranscript(t, transcriptPath, "Changed reply")
805+
initialTime := time.Unix(0, initialMtime)
806+
require.NoError(t, os.Chtimes(transcriptPath, initialTime, initialTime))
807+
second := engine.processFile(context.Background(), file)
808+
require.NoError(t, second.err)
809+
assert.False(t, second.skip)
810+
require.Len(t, second.results, 1)
811+
require.Len(t, second.results[0].Messages, 2)
812+
assert.Equal(t, "Changed reply", second.results[0].Messages[1].Content)
813+
changedHash := second.results[0].Session.File.Hash
814+
require.NotEmpty(t, changedHash)
815+
require.NotEqual(t, initialHash, changedHash)
816+
require.Contains(t, second.cacheKey, "?source_hash="+changedHash)
817+
writeProcessProviderDevinResult(t, engine, second)
818+
engine.clearSkip(second.cacheKey)
819+
820+
writeProcessProviderDevinTranscript(t, transcriptPath, "Initial reply")
821+
require.NoError(t, os.Chtimes(transcriptPath, initialTime, initialTime))
822+
823+
third := engine.processFile(context.Background(), file)
824+
require.NoError(t, third.err)
825+
assert.False(t, third.skip)
826+
require.Len(t, third.results, 1)
827+
require.Len(t, third.results[0].Messages, 2)
828+
assert.Equal(t, "Initial reply", third.results[0].Messages[1].Content)
829+
assert.Equal(t, initialHash, third.results[0].Session.File.Hash)
830+
}
831+
767832
func TestSyncAllProviderDevinMissingDBPreservesArchive(t *testing.T) {
768833
root := t.TempDir()
769834
dbPath, _ := writeProcessProviderDevinFixture(
@@ -843,6 +908,27 @@ func writeProcessProviderForgeDB(t *testing.T, root string) string {
843908
return dbPath
844909
}
845910

911+
func writeProcessProviderDevinResult(
912+
t *testing.T,
913+
engine *Engine,
914+
result processResult,
915+
) {
916+
t.Helper()
917+
require.Len(t, result.results, 1)
918+
written, _, failed := engine.writeBatch(
919+
[]pendingWrite{{
920+
sess: result.results[0].Session,
921+
msgs: result.results[0].Messages,
922+
usageEvents: result.results[0].UsageEvents,
923+
forceReplace: result.forceReplace,
924+
}},
925+
syncWriteDefault,
926+
false,
927+
)
928+
require.Equal(t, 0, failed)
929+
require.Equal(t, 1, written)
930+
}
931+
846932
func writeProcessProviderDevinFixture(
847933
t *testing.T,
848934
root string,

0 commit comments

Comments
 (0)