Skip to content

Commit b5363ab

Browse files
committed
fix(codebuff): address review findings - ForceReplace, ID lookup, model, lifecycle
1. ForceReplace: Created codebuffSourceSet wrapper that overrides Parse to set ForceReplace: true on successful parses, ensuring full message replacement for the mutable transcript. 2. codebuffFindFile: Updated to split project:timestamp in raw ID for the new session ID format, with legacy timestamp-only fallback. 3. Usage events: Added placeholder model name "codebuff" so usage queries filter correctly and cost display works. 4. File-path policies: Made alias-aware for both Codebuff and Freebuff in ListSessionIDsByFilePath and HasTrashedSessionByFilePath.
1 parent 4fe008d commit b5363ab

3 files changed

Lines changed: 79 additions & 14 deletions

File tree

internal/parser/codebuff.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,15 @@ func parseCodebuffSession(
187187
// this value. Leave peak context unavailable.
188188

189189
// Emit usage event for reported credits. The actual model is unknown
190-
// (selected server-side, can change mid-session), so Model is left
191-
// empty. Credits are billing units (1 credit = $0.01), mapped to
192-
// CostUSD for cost display.
190+
// (selected server-side, can change mid-session), so use a placeholder
191+
// model name. Usage queries filter out events with empty model, so a
192+
// non-empty value is required for cost display.
193193
if rs.CreditsUsed > 0 {
194194
cost := rs.CreditsUsed * 0.01
195195
sess.UsageEvents = []ParsedUsageEvent{{
196196
SessionID: fullID,
197197
Source: "session",
198+
Model: "codebuff",
198199
OccurredAt: func() string {
199200
if !endedAt.IsZero() {
200201
return endedAt.Format(time.RFC3339Nano)

internal/parser/codebuff_provider.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package parser
22

33
import (
4+
"context"
45
"crypto/sha256"
56
"fmt"
67
"os"
@@ -12,11 +13,11 @@ import (
1213
// Freebuff sessions are handled through the same provider and distinguished
1314
// via AgentLabel rather than a separate agent type.
1415
func newCodebuffProviderFactory(def AgentDef) ProviderFactory {
15-
return NewSingleFileProviderFactory(
16+
return NewSourceSetFactory(
1617
def,
1718
codebuffProviderCapabilities(),
18-
func(cfg ProviderConfig) singleFileSourceSet {
19-
return NewSingleFileSourceSet(
19+
func(cfg ProviderConfig) SourceSet {
20+
inner := NewSingleFileSourceSet(
2021
def.Type,
2122
cfg.Roots,
2223
WithStreamingFileDiscovery(codebuffDiscoverEach),
@@ -38,10 +39,30 @@ func newCodebuffProviderFactory(def AgentDef) ProviderFactory {
3839
return codebuffParseFile(src, req)
3940
}),
4041
)
42+
return codebuffSourceSet{inner}
4143
},
4244
)
4345
}
4446

47+
// codebuffSourceSet wraps singleFileSourceSet to force full message
48+
// replacement on every successful parse. Codebuff reparses the entire
49+
// mutable JSON transcript on every sync, so the append-only writer
50+
// would leave stale ordinals and missed in-place block updates.
51+
type codebuffSourceSet struct {
52+
singleFileSourceSet
53+
}
54+
55+
func (s codebuffSourceSet) Parse(
56+
ctx context.Context,
57+
req ParseRequest,
58+
) (ParseOutcome, error) {
59+
outcome, err := s.singleFileSourceSet.Parse(ctx, req)
60+
if err == nil && outcome.ResultSetComplete && len(outcome.Results) > 0 {
61+
outcome.ForceReplace = true
62+
}
63+
return outcome, err
64+
}
65+
4566
// codebuffWatchRoots creates watch plans for recursive watching of
4667
// each root. Since sessions are two levels deep under chats/, we need
4768
// recursive watching.
@@ -101,9 +122,28 @@ func codebuffClassifyPath(
101122
return singleFileMatch{}, false
102123
}
103124

104-
// codebuffFindFile finds a session by raw session ID (timestamp) under
105-
// the root. Searches across all project subdirectories.
125+
// codebuffFindFile finds a session by raw session ID under the root.
126+
// The rawID may be either "project:timestamp" (new format) or just
127+
// "timestamp" (legacy compatibility). For the new format, it searches
128+
// the specific project directory. For legacy format, it searches all
129+
// project subdirectories.
106130
func codebuffFindFile(root, rawID string) (singleFileMatch, bool) {
131+
// Try to split into project:timestamp.
132+
parts := strings.SplitN(rawID, ":", 2)
133+
if len(parts) == 2 {
134+
projectName := parts[0]
135+
timestamp := parts[1]
136+
chatPath := filepath.Join(root, projectName, "chats", timestamp, "chat-messages.json")
137+
if IsRegularFile(chatPath) {
138+
return singleFileMatch{
139+
Path: chatPath,
140+
ProjectHint: projectName,
141+
}, true
142+
}
143+
return singleFileMatch{}, false
144+
}
145+
146+
// Legacy format: search all projects for the timestamp.
107147
projects, err := os.ReadDir(root)
108148
if err != nil {
109149
return singleFileMatch{}, false

internal/sync/engine.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7407,18 +7407,42 @@ func (e *Engine) applyProviderFilePathPolicies(
74077407
currentID := result.Session.ID
74087408
currentPrefixedID := e.idPrefix + result.Session.ID
74097409

7410-
existingIDs, err := e.db.ListSessionIDsByFilePath(lookupPath, string(agent))
7411-
if err != nil {
7412-
log.Printf("list session IDs by file path: %v", err)
7413-
kept = append(kept, result)
7414-
continue
7410+
// Freebuff shares the Codebuff provider. Query both agent types
7411+
// so stale rows and resurrection guards work for both.
7412+
agentsToQuery := []string{string(agent)}
7413+
if agent == parser.AgentCodebuff {
7414+
agentsToQuery = append(agentsToQuery, string(parser.AgentFreebuff))
7415+
}
7416+
var existingIDs []string
7417+
for _, agentStr := range agentsToQuery {
7418+
ids, err := e.db.ListSessionIDsByFilePath(lookupPath, agentStr)
7419+
if err != nil {
7420+
log.Printf("list session IDs by file path: %v", err)
7421+
continue
7422+
}
7423+
existingIDs = append(existingIDs, ids...)
7424+
}
7425+
if len(existingIDs) == 0 && len(agentsToQuery) > 1 {
7426+
// Check if the query for the primary agent failed.
7427+
_, err := e.db.ListSessionIDsByFilePath(lookupPath, string(agent))
7428+
if err != nil {
7429+
log.Printf("list session IDs by file path: %v", err)
7430+
kept = append(kept, result)
7431+
continue
7432+
}
74157433
}
74167434

74177435
// Resurrection guard. The path's identity is removed when a trashed row
74187436
// shares it, or when any alternate identity for the path (the
74197437
// provider's excluded fallback IDs or a stale stored ID) is trashed or
74207438
// permanently excluded. In that case the new row must not be written.
7421-
suppress := e.db.HasTrashedSessionByFilePath(lookupPath, string(agent))
7439+
suppress := false
7440+
for _, agentStr := range agentsToQuery {
7441+
if e.db.HasTrashedSessionByFilePath(lookupPath, agentStr) {
7442+
suppress = true
7443+
break
7444+
}
7445+
}
74227446
if !suppress {
74237447
for id := range excluded {
74247448
if id == currentID || id == currentPrefixedID {

0 commit comments

Comments
 (0)