Skip to content

Commit 2b8eaa1

Browse files
peyton-altclaude
andcommitted
fix(attribution): review feedback — correctness fixes, deduplication, and tests
- Fix tree/hash fallback mismatch: keep nonAgentBaseTree and nonAgentBaseCommit in sync when parent resolution fails, preventing inconsistent attribution from dual-path getAllChangedFiles - Remove redundant parentCommitHash re-derivation in postCommitProcessSession — use the already-threaded parameter - Extract resolveCommitContext helper to deduplicate ~25 lines of HEAD/parent tree resolution between PostCommit and CondenseSessionByID; also adds missing warnings in CondenseSessionByID when repo.Head() fails - Add attrBase divergence warning in computeCombinedAttribution - Only accumulate condensed sessions for combined attribution (snapshot PromptAttributions before condensation, commit to accumulator after) - Add nil guard to UpdateCheckpointSummary to avoid unnecessary git commits - Add 4 unit tests for UpdateCheckpointSummary (happy path, not found, field preservation, nil no-op) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cb0f3d7 commit 2b8eaa1

5 files changed

Lines changed: 215 additions & 82 deletions

File tree

cmd/entire/cli/checkpoint/committed.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,10 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint
11061106
// for an existing checkpoint, setting CombinedAttribution.
11071107
// Returns ErrCheckpointNotFound if the checkpoint does not exist.
11081108
func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, opts UpdateCheckpointSummaryOptions) error {
1109+
if opts.CombinedAttribution == nil {
1110+
return nil // No-op: nothing to patch
1111+
}
1112+
11091113
if err := ctx.Err(); err != nil {
11101114
return err //nolint:wrapcheck // Propagating context cancellation
11111115
}

cmd/entire/cli/checkpoint/committed_update_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,5 +580,122 @@ func TestGetGitAuthorFromRepo_NoConfig(t *testing.T) {
580580
}
581581
}
582582

583+
func TestUpdateCheckpointSummary_SetsCombinedAttribution(t *testing.T) {
584+
t.Parallel()
585+
_, store, cpID := setupRepoForUpdate(t)
586+
587+
combined := &InitialAttribution{
588+
AgentLines: 15,
589+
HumanAdded: 3,
590+
HumanModified: 1,
591+
HumanRemoved: 0,
592+
TotalCommitted: 18,
593+
AgentPercentage: 83.3,
594+
}
595+
596+
err := store.UpdateCheckpointSummary(context.Background(), UpdateCheckpointSummaryOptions{
597+
CheckpointID: cpID,
598+
CombinedAttribution: combined,
599+
})
600+
if err != nil {
601+
t.Fatalf("UpdateCheckpointSummary() error = %v", err)
602+
}
603+
604+
summary, err := store.ReadCommitted(context.Background(), cpID)
605+
if err != nil {
606+
t.Fatalf("ReadCommitted() error = %v", err)
607+
}
608+
609+
if summary.CombinedAttribution == nil {
610+
t.Fatal("CombinedAttribution is nil after update")
611+
}
612+
if summary.CombinedAttribution.AgentLines != 15 {
613+
t.Errorf("AgentLines = %d, want 15", summary.CombinedAttribution.AgentLines)
614+
}
615+
if summary.CombinedAttribution.HumanAdded != 3 {
616+
t.Errorf("HumanAdded = %d, want 3", summary.CombinedAttribution.HumanAdded)
617+
}
618+
if summary.CombinedAttribution.AgentPercentage != 83.3 {
619+
t.Errorf("AgentPercentage = %.1f, want 83.3", summary.CombinedAttribution.AgentPercentage)
620+
}
621+
}
622+
623+
func TestUpdateCheckpointSummary_NonexistentCheckpoint(t *testing.T) {
624+
t.Parallel()
625+
_, store, _ := setupRepoForUpdate(t)
626+
627+
err := store.UpdateCheckpointSummary(context.Background(), UpdateCheckpointSummaryOptions{
628+
CheckpointID: id.MustCheckpointID("deadbeef1234"),
629+
CombinedAttribution: &InitialAttribution{
630+
AgentLines: 10,
631+
},
632+
})
633+
if err == nil {
634+
t.Fatal("expected error for nonexistent checkpoint, got nil")
635+
}
636+
}
637+
638+
func TestUpdateCheckpointSummary_PreservesExistingFields(t *testing.T) {
639+
t.Parallel()
640+
_, store, cpID := setupRepoForUpdate(t)
641+
642+
summaryBefore, err := store.ReadCommitted(context.Background(), cpID)
643+
if err != nil {
644+
t.Fatalf("ReadCommitted() before error = %v", err)
645+
}
646+
647+
err = store.UpdateCheckpointSummary(context.Background(), UpdateCheckpointSummaryOptions{
648+
CheckpointID: cpID,
649+
CombinedAttribution: &InitialAttribution{
650+
AgentLines: 10,
651+
TotalCommitted: 10,
652+
AgentPercentage: 100.0,
653+
},
654+
})
655+
if err != nil {
656+
t.Fatalf("UpdateCheckpointSummary() error = %v", err)
657+
}
658+
659+
summaryAfter, err := store.ReadCommitted(context.Background(), cpID)
660+
if err != nil {
661+
t.Fatalf("ReadCommitted() after error = %v", err)
662+
}
663+
664+
if summaryAfter.CheckpointID != summaryBefore.CheckpointID {
665+
t.Errorf("CheckpointID changed: %q -> %q", summaryBefore.CheckpointID, summaryAfter.CheckpointID)
666+
}
667+
if summaryAfter.Strategy != summaryBefore.Strategy {
668+
t.Errorf("Strategy changed: %q -> %q", summaryBefore.Strategy, summaryAfter.Strategy)
669+
}
670+
if len(summaryAfter.Sessions) != len(summaryBefore.Sessions) {
671+
t.Errorf("Sessions count changed: %d -> %d", len(summaryBefore.Sessions), len(summaryAfter.Sessions))
672+
}
673+
if summaryAfter.CombinedAttribution == nil {
674+
t.Fatal("CombinedAttribution should be set after update")
675+
}
676+
}
677+
678+
func TestUpdateCheckpointSummary_NilCombinedAttributionIsNoop(t *testing.T) {
679+
t.Parallel()
680+
_, store, cpID := setupRepoForUpdate(t)
681+
682+
// Nil CombinedAttribution should be a no-op (no error, no unnecessary commit)
683+
err := store.UpdateCheckpointSummary(context.Background(), UpdateCheckpointSummaryOptions{
684+
CheckpointID: cpID,
685+
CombinedAttribution: nil,
686+
})
687+
if err != nil {
688+
t.Fatalf("UpdateCheckpointSummary(nil) error = %v", err)
689+
}
690+
691+
summary, err := store.ReadCommitted(context.Background(), cpID)
692+
if err != nil {
693+
t.Fatalf("ReadCommitted() error = %v", err)
694+
}
695+
if summary.CombinedAttribution != nil {
696+
t.Errorf("CombinedAttribution should remain nil, got %+v", summary.CombinedAttribution)
697+
}
698+
}
699+
583700
// Verify go-git config import is used (compile-time check).
584701
var _ = config.GlobalScope

cmd/entire/cli/strategy/manual_commit_attribution.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,24 +248,25 @@ func CalculateAttributionWithAccumulated(
248248
// Calculate total user edits to non-agent files (files not in filesTouched).
249249
// Scope this to the attributed commit only by diffing parent→head. If parent
250250
// context is unavailable, fall back to the session base behavior.
251+
// IMPORTANT: nonAgentBaseTree and nonAgentBaseCommit must reference the same
252+
// commit — getAllChangedFiles has a fast path (CLI diff-tree using hashes) and
253+
// a slow path (go-git tree walk using trees). If they diverge, attribution
254+
// produces inconsistent results depending on which path fires.
251255
nonAgentBaseTree := baseTree
256+
nonAgentBaseCommit := attributionBaseCommit
252257
if parentTree != nil {
253258
nonAgentBaseTree = parentTree
259+
nonAgentBaseCommit = parentCommitHash
254260
} else if parentCommitHash != "" {
255261
// parentCommitHash is set but parentTree is nil — parent object resolution
256-
// failed (e.g. shallow clone, pack corruption). Fall back to session base,
257-
// but warn because non-agent file counts may be inflated.
262+
// failed (e.g. shallow clone, pack corruption). Fall back to session base
263+
// for both tree and hash to keep them in sync.
258264
logging.Warn(logging.WithComponent(ctx, "attribution"),
259265
"attribution: parent tree unavailable despite parent hash being set; non-agent file counts may be inflated",
260266
slog.String("parent_commit_hash", parentCommitHash),
261267
)
262268
}
263269

264-
nonAgentBaseCommit := attributionBaseCommit
265-
if parentCommitHash != "" {
266-
nonAgentBaseCommit = parentCommitHash
267-
}
268-
269270
allChangedFiles, err := getAllChangedFiles(ctx, nonAgentBaseTree, headTree, repoDir, nonAgentBaseCommit, headCommitHash)
270271
if err != nil {
271272
logging.Warn(logging.WithComponent(ctx, "attribution"),

cmd/entire/cli/strategy/manual_commit_condensation.go

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,11 @@ func (s *ManualCommitStrategy) getCheckpointLog(ctx context.Context, checkpointI
8888
// sessionAttrData captures per-session data needed to compute combined attribution
8989
// after the condensation loop has cleared state.PromptAttributions.
9090
type sessionAttrData struct {
91+
condenseOpts // Embedded: shadowRef, headTree, parentTree, repoDir, headCommitHash, parentCommitHash
92+
9193
promptAttributions []PromptAttribution
9294
filesTouched []string
93-
shadowRef *plumbing.Reference
94-
headTree *object.Tree
95-
parentTree *object.Tree
96-
repoDir string
9795
attrBase string
98-
headCommit string
99-
parentCommit string
10096
}
10197

10298
// computeCombinedAttribution merges PromptAttributions from all sessions sharing
@@ -125,10 +121,22 @@ func computeCombinedAttribution(
125121
}
126122

127123
first := sessions[0]
124+
attrBase := first.attrBase
125+
for i := 1; i < len(sessions); i++ {
126+
if sessions[i].attrBase != attrBase {
127+
logging.Warn(logging.WithComponent(ctx, "attribution"),
128+
"combined attribution: sessions have divergent attribution base commits; using first session's base",
129+
slog.String("first_base", attrBase),
130+
slog.String("divergent_base", sessions[i].attrBase),
131+
slog.Int("session_index", i),
132+
)
133+
break
134+
}
135+
}
128136
syntheticState := &SessionState{
129137
PromptAttributions: merged,
130-
AttributionBaseCommit: first.attrBase,
131-
BaseCommit: first.attrBase,
138+
AttributionBaseCommit: attrBase,
139+
BaseCommit: attrBase,
132140
}
133141
syntheticData := &ExtractedSessionData{
134142
FilesTouched: allFilesTouched,
@@ -139,19 +147,53 @@ func computeCombinedAttribution(
139147
parentTree: first.parentTree,
140148
repoDir: first.repoDir,
141149
attributionBaseCommit: first.attrBase,
142-
headCommitHash: first.headCommit,
143-
parentCommitHash: first.parentCommit,
150+
headCommitHash: first.headCommitHash,
151+
parentCommitHash: first.parentCommitHash,
144152
})
145153
}
146154

155+
// resolveCommitContext extracts HEAD tree, parent tree, and parent commit hash
156+
// from a commit object. Best-effort: warns on failure and leaves fields nil/empty.
157+
// Both parentTree and parentCommitHash are set together or not at all, so callers
158+
// can rely on them being in sync.
159+
func resolveCommitContext(ctx context.Context, commit *object.Commit) (headTree, parentTree *object.Tree, parentCommitHash string) {
160+
logCtx := logging.WithComponent(ctx, "checkpoint")
161+
162+
if t, err := commit.Tree(); err != nil {
163+
logging.Warn(logCtx, "failed to resolve HEAD tree; attribution will be skipped",
164+
slog.String("commit", commit.Hash.String()),
165+
slog.String("error", err.Error()))
166+
} else {
167+
headTree = t
168+
}
169+
170+
if commit.NumParents() > 0 {
171+
rawHash := commit.ParentHashes[0]
172+
if parent, err := commit.Parent(0); err != nil {
173+
logging.Warn(logCtx, "failed to load parent commit; parent-scoped attribution unavailable",
174+
slog.String("parent_hash", rawHash.String()),
175+
slog.String("error", err.Error()))
176+
} else if t, err := parent.Tree(); err != nil {
177+
logging.Warn(logCtx, "failed to load parent tree; parent-scoped attribution unavailable",
178+
slog.String("parent_hash", rawHash.String()),
179+
slog.String("error", err.Error()))
180+
} else {
181+
parentTree = t
182+
parentCommitHash = rawHash.String()
183+
}
184+
}
185+
186+
return headTree, parentTree, parentCommitHash
187+
}
188+
147189
// condenseOpts provides pre-resolved git objects to avoid redundant reads.
148190
type condenseOpts struct {
149191
shadowRef *plumbing.Reference // Pre-resolved shadow branch ref (nil = resolve from repo)
150192
headTree *object.Tree // Pre-resolved HEAD tree (passed through to calculateSessionAttributions)
151193
repoDir string // Repository worktree path for git CLI commands
152194
headCommitHash string // HEAD commit hash (passed through for attribution)
153-
parentTree *object.Tree // HEAD's first parent tree (nil iff parentCommitHash is empty)
154-
parentCommitHash string // HEAD's first parent hash (empty iff parentTree is nil — initial commit or resolution failure)
195+
parentTree *object.Tree // HEAD's first parent tree (nil when parentCommitHash is empty or parent resolution failed)
196+
parentCommitHash string // HEAD's first parent hash (empty when parentTree is nil — initial commit or resolution failure)
155197
}
156198

157199
// CondenseSession condenses a session's shadow branch to permanent storage.
@@ -912,31 +954,17 @@ func (s *ManualCommitStrategy) CondenseSessionByID(ctx context.Context, sessionI
912954
if repoDir, rdErr := paths.WorktreeRoot(ctx); rdErr == nil {
913955
headCommitOpts.repoDir = repoDir
914956
}
915-
if headRef, hrErr := repo.Head(); hrErr == nil {
957+
if headRef, hrErr := repo.Head(); hrErr != nil {
958+
logging.Warn(logCtx, "condense-by-id: failed to resolve HEAD; attribution context unavailable",
959+
slog.String("error", hrErr.Error()))
960+
} else {
916961
headCommitOpts.headCommitHash = headRef.Hash().String()
917-
if headCommit, hcErr := repo.CommitObject(headRef.Hash()); hcErr == nil {
918-
if t, tErr := headCommit.Tree(); tErr != nil {
919-
logging.Warn(logCtx, "condense-by-id: failed to resolve HEAD tree; attribution will be skipped",
920-
slog.String("commit", headRef.Hash().String()),
921-
slog.String("error", tErr.Error()))
922-
} else {
923-
headCommitOpts.headTree = t
924-
}
925-
if headCommit.NumParents() > 0 {
926-
rawHash := headCommit.ParentHashes[0]
927-
if parent, pErr := headCommit.Parent(0); pErr != nil {
928-
logging.Warn(logCtx, "condense-by-id: failed to load parent commit; parent-scoped attribution unavailable",
929-
slog.String("parent_hash", rawHash.String()),
930-
slog.String("error", pErr.Error()))
931-
} else if t, tErr := parent.Tree(); tErr != nil {
932-
logging.Warn(logCtx, "condense-by-id: failed to load parent tree; parent-scoped attribution unavailable",
933-
slog.String("parent_hash", rawHash.String()),
934-
slog.String("error", tErr.Error()))
935-
} else {
936-
headCommitOpts.parentTree = t
937-
headCommitOpts.parentCommitHash = rawHash.String()
938-
}
939-
}
962+
if headCommit, hcErr := repo.CommitObject(headRef.Hash()); hcErr != nil {
963+
logging.Warn(logCtx, "condense-by-id: failed to load HEAD commit object",
964+
slog.String("commit", headRef.Hash().String()),
965+
slog.String("error", hcErr.Error()))
966+
} else {
967+
headCommitOpts.headTree, headCommitOpts.parentTree, headCommitOpts.parentCommitHash = resolveCommitContext(ctx, headCommit)
940968
}
941969
}
942970

cmd/entire/cli/strategy/manual_commit_hooks.go

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -846,31 +846,7 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:
846846
// per-session functions (filesOverlapWithContent, filesWithRemainingAgentChanges,
847847
// calculateSessionAttributions).
848848
_, resolveTreesSpan := perf.Start(ctx, "resolve_commit_trees")
849-
var headTree *object.Tree
850-
if t, err := commit.Tree(); err != nil {
851-
logging.Warn(logCtx, "post-commit: failed to resolve HEAD tree; attribution will be skipped",
852-
slog.String("commit", commit.Hash.String()),
853-
slog.String("error", err.Error()))
854-
} else {
855-
headTree = t
856-
}
857-
var parentTree *object.Tree
858-
var parentCommitHash string
859-
if commit.NumParents() > 0 {
860-
rawHash := commit.ParentHashes[0]
861-
if parent, err := commit.Parent(0); err != nil {
862-
logging.Warn(logCtx, "post-commit: failed to load parent commit; parent-scoped attribution unavailable",
863-
slog.String("parent_hash", rawHash.String()),
864-
slog.String("error", err.Error()))
865-
} else if t, err := parent.Tree(); err != nil {
866-
logging.Warn(logCtx, "post-commit: failed to load parent tree; parent-scoped attribution unavailable",
867-
slog.String("parent_hash", rawHash.String()),
868-
slog.String("error", err.Error()))
869-
} else {
870-
parentTree = t
871-
parentCommitHash = rawHash.String()
872-
}
873-
}
849+
headTree, parentTree, parentCommitHash := resolveCommitContext(ctx, commit)
874850

875851
committedFileSet := filesChangedInCommit(ctx, worktreePath, commit, headTree, parentTree)
876852
resolveTreesSpan.End()
@@ -1043,28 +1019,28 @@ func (s *ManualCommitStrategy) postCommitProcessSession(
10431019
slog.Any("files", filesTouchedBefore),
10441020
)
10451021

1046-
// Collect PromptAttributions BEFORE condensation clears them.
1022+
// Snapshot PromptAttributions BEFORE condensation clears them.
10471023
// condenseAndUpdateState sets state.PromptAttributions = nil, so we must
1048-
// capture them here for the combined attribution pass after the session loop.
1024+
// capture them here. The snapshot is only committed to attrAccumulator after
1025+
// we confirm condensation occurred (below), to avoid computing combined
1026+
// attribution for sessions that weren't actually condensed.
10491027
attrBase := state.AttributionBaseCommit
10501028
if attrBase == "" {
10511029
attrBase = state.BaseCommit
10521030
}
1053-
var parentCommit string
1054-
if commit.NumParents() > 0 {
1055-
parentCommit = commit.ParentHashes[0].String()
1056-
}
1057-
attrAccumulator[shadowBranchName] = append(attrAccumulator[shadowBranchName], sessionAttrData{
1031+
snapshotAttr := sessionAttrData{
1032+
condenseOpts: condenseOpts{
1033+
shadowRef: shadowRef,
1034+
headTree: headTree,
1035+
parentTree: parentTree,
1036+
repoDir: repoDir,
1037+
headCommitHash: newHead,
1038+
parentCommitHash: parentCommitHash,
1039+
},
10581040
promptAttributions: append([]PromptAttribution(nil), state.PromptAttributions...),
10591041
filesTouched: append([]string(nil), filesTouchedBefore...),
1060-
shadowRef: shadowRef,
1061-
headTree: headTree,
1062-
parentTree: parentTree,
1063-
repoDir: repoDir,
10641042
attrBase: attrBase,
1065-
headCommit: newHead,
1066-
parentCommit: parentCommit,
1067-
})
1043+
}
10681044

10691045
// Run the state machine transition with handler for strategy-specific actions.
10701046
_, transitionAndCondenseSpan := perf.Start(ctx, "transition_and_condense")
@@ -1095,6 +1071,13 @@ func (s *ManualCommitStrategy) postCommitProcessSession(
10951071
}
10961072
transitionAndCondenseSpan.End()
10971073

1074+
// Only include this session in the combined attribution accumulator if it
1075+
// was actually condensed. Non-condensed sessions haven't written their data
1076+
// to the checkpoint yet, so including them would produce premature results.
1077+
if handler.condensed {
1078+
attrAccumulator[shadowBranchName] = append(attrAccumulator[shadowBranchName], snapshotAttr)
1079+
}
1080+
10981081
// Record checkpoint ID for ACTIVE sessions so HandleTurnEnd can finalize
10991082
// with full transcript. IDLE/ENDED sessions already have complete transcripts.
11001083
// NOTE: This check runs AFTER TransitionAndLog updated the phase. It relies on

0 commit comments

Comments
 (0)