Skip to content

Commit dd47f15

Browse files
fix(attribution): actionable guidance when checkpoint metadata is missing; refine session-fallback caveat
Addresses review feedback on #1535. Karthik's report (community Discord + karthik-rameshkumar#1): `entire why` on a line whose checkpoint metadata is missing printed "Full context: entire checkpoint explain <id>", but running that command fails identically ("no checkpoint or commit found matching <id>") because the same remote fetch `why` attempts has already failed. The hint pointed at a command that immediately errors. - Resolve metadata-missing during attribution (resolveFileAttribution with fetchOnMiss) and record a MetadataMissingReason that states the cause and the actionable remedy: `git fetch <remote> entire/checkpoints/v1:...`, then re-run `entire checkpoint explain <id>`. Surfaced in the line and file `why` views and in JSON. Also drops the separate post-resolution enrich step, which left the checkpoint map non-deterministic across calls. (Mechanism and tests adapted from Karthik's PR.) - Suppress the bare "Full context: entire checkpoint explain <id>" hint when metadata is missing: it would fail the same way, and the reason line above now gives the fetch-then-explain sequence. - Refine the session-fallback caveat (Soph's review): flag the multi-session fallback whose chosen session has empty FilesTouched (one of several sessions, no path evidence) via `sessionsRead > 1 || len(FilesTouched) > 0`, while still suppressing the single-session empty-paths false positive. Tests for both. Co-authored-by: karthik-rameshkumar <6728802+karthik-rameshkumar@users.noreply.github.com>
1 parent a1e791e commit dd47f15

2 files changed

Lines changed: 172 additions & 61 deletions

File tree

‎cmd/entire/cli/attribution.go‎

Lines changed: 56 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,22 @@ type rawBlameLine struct {
5252
}
5353

5454
type attributionLine struct {
55-
LineNumber int `json:"line_number"`
56-
Authorship attributionAuthorship `json:"authorship"`
57-
Tag string `json:"tag"`
58-
CommitSHA string `json:"commit_sha,omitempty"`
59-
ShortCommitSHA string `json:"short_commit_sha,omitempty"`
60-
Author string `json:"author,omitempty"`
61-
AuthorTime *time.Time `json:"author_time,omitempty"`
62-
CheckpointID string `json:"checkpoint_id,omitempty"`
63-
SessionID string `json:"session_id,omitempty"`
64-
Agent string `json:"agent,omitempty"`
65-
Model string `json:"model,omitempty"`
66-
Prompt string `json:"prompt,omitempty"`
67-
Intent string `json:"intent,omitempty"`
68-
MetadataMissing bool `json:"metadata_missing,omitempty"`
69-
SessionFallback bool `json:"session_fallback,omitempty"`
55+
LineNumber int `json:"line_number"`
56+
Authorship attributionAuthorship `json:"authorship"`
57+
Tag string `json:"tag"`
58+
CommitSHA string `json:"commit_sha,omitempty"`
59+
ShortCommitSHA string `json:"short_commit_sha,omitempty"`
60+
Author string `json:"author,omitempty"`
61+
AuthorTime *time.Time `json:"author_time,omitempty"`
62+
CheckpointID string `json:"checkpoint_id,omitempty"`
63+
SessionID string `json:"session_id,omitempty"`
64+
Agent string `json:"agent,omitempty"`
65+
Model string `json:"model,omitempty"`
66+
Prompt string `json:"prompt,omitempty"`
67+
Intent string `json:"intent,omitempty"`
68+
MetadataMissing bool `json:"metadata_missing,omitempty"`
69+
MetadataMissingReason string `json:"metadata_missing_reason,omitempty"`
70+
SessionFallback bool `json:"session_fallback,omitempty"`
7071
// PromptSessionLevel is set when Prompt is the session's overall/seed prompt
7172
// (e.g. an attach/trail ReviewPrompt) rather than a prompt recorded for this
7273
// specific checkpoint. `why` labels these differently and points at
@@ -84,15 +85,16 @@ type attributionLine struct {
8485
// deduplicated per-file checkpoint map — so attributionCandidate aliases it
8586
// rather than duplicating the fields.
8687
type attributionCheckpointContext struct {
87-
CheckpointID string `json:"checkpoint_id"`
88-
SessionID string `json:"session_id,omitempty"`
89-
Agent string `json:"agent,omitempty"`
90-
Model string `json:"model,omitempty"`
91-
Prompt string `json:"prompt,omitempty"`
92-
Intent string `json:"intent,omitempty"`
93-
FilesTouched []string `json:"files_touched,omitempty"`
94-
MetadataMissing bool `json:"metadata_missing,omitempty"`
95-
Mixed bool `json:"mixed,omitempty"`
88+
CheckpointID string `json:"checkpoint_id"`
89+
SessionID string `json:"session_id,omitempty"`
90+
Agent string `json:"agent,omitempty"`
91+
Model string `json:"model,omitempty"`
92+
Prompt string `json:"prompt,omitempty"`
93+
Intent string `json:"intent,omitempty"`
94+
FilesTouched []string `json:"files_touched,omitempty"`
95+
MetadataMissing bool `json:"metadata_missing,omitempty"`
96+
MetadataMissingReason string `json:"metadata_missing_reason,omitempty"`
97+
Mixed bool `json:"mixed,omitempty"`
9698
// SessionFallback is set when the file is not in any resolved session's
9799
// recorded paths (e.g. it was renamed after the checkpoint) and the
98100
// agent/prompt shown is a best-effort guess from the checkpoint's first
@@ -255,7 +257,9 @@ func runAttributionWhy(ctx context.Context, w io.Writer, target string, opts att
255257
line, hasLine = n, true
256258
}
257259

258-
result, err := resolveFileAttribution(ctx, file, false)
260+
// entire why is explanation-focused: when local metadata is missing it
261+
// should attempt the same remote enrichment path as checkpoint explain.
262+
result, err := resolveFileAttribution(ctx, file, true)
259263
if err != nil {
260264
return err
261265
}
@@ -278,13 +282,6 @@ func runAttributionWhy(ctx context.Context, w io.Writer, target string, opts att
278282
if selected == nil {
279283
return fmt.Errorf("line %d is outside %s", line, result.File)
280284
}
281-
if selected.MetadataMissing && selected.CheckpointID != "" {
282-
if err := enrichAttributionLineWithFetch(ctx, result.File, selected, result.Checkpoints); err != nil {
283-
// Remote metadata enrichment is best-effort; the trailer-level
284-
// explanation is still useful and should remain available.
285-
selected.MetadataMissing = true
286-
}
287-
}
288285

289286
if opts.JSON {
290287
payload := struct {
@@ -334,8 +331,9 @@ func resolveFileAttribution(ctx context.Context, file string, fetchOnMiss bool)
334331
for _, candidate := range line.Candidates {
335332
if candidate.MetadataMissing {
336333
result.Checkpoints[candidate.CheckpointID] = attributionCheckpointContext{
337-
CheckpointID: candidate.CheckpointID,
338-
MetadataMissing: true,
334+
CheckpointID: candidate.CheckpointID,
335+
MetadataMissing: true,
336+
MetadataMissingReason: candidate.MetadataMissingReason,
339337
}
340338
continue
341339
}
@@ -450,12 +448,15 @@ func (r *attributionResolver) readCheckpointContext(cpID id.CheckpointID, file s
450448
ctx := attributionCheckpointContext{CheckpointID: cpID.String()}
451449
summary, err := readAttributionCheckpointSummary(r.ctx, r.store, cpID)
452450
if err != nil && r.fetchOnMiss {
453-
if fetched, fetchErr := r.fetchCheckpointContext(cpID, file); fetchErr == nil {
451+
fetched, fetchErr := r.fetchCheckpointContext(cpID, file)
452+
if fetchErr == nil {
454453
return fetched
455454
}
455+
err = fmt.Errorf("%w (remote refresh failed: %v)", err, fetchErr)
456456
}
457457
if err != nil {
458458
ctx.MetadataMissing = true
459+
ctx.MetadataMissingReason = metadataMissingReason(r.ctx, cpID.String(), err)
459460
return ctx
460461
}
461462

@@ -547,33 +548,15 @@ func readAttributionCheckpointSummary(ctx context.Context, reader attributionChe
547548
return summary, nil
548549
}
549550

550-
func enrichAttributionLineWithFetch(ctx context.Context, file string, line *attributionLine, checkpoints map[string]attributionCheckpointContext) error {
551-
if line == nil || len(line.Candidates) == 0 {
552-
return nil
551+
func metadataMissingReason(ctx context.Context, checkpointID string, cause error) string {
552+
reason := "checkpoint metadata was not found locally"
553+
if cause != nil {
554+
reason = fmt.Sprintf("%s (%v)", reason, cause)
553555
}
554-
resolver, err := newAttributionResolver(ctx, true)
555-
if err != nil {
556-
return err
557-
}
558-
defer resolver.Close()
559-
560-
candidates := make([]attributionCandidate, 0, len(line.Candidates))
561-
for _, candidate := range line.Candidates {
562-
cpID, idErr := id.NewCheckpointID(candidate.CheckpointID)
563-
if idErr != nil {
564-
candidates = append(candidates, candidate)
565-
continue
566-
}
567-
cpCtx := resolver.checkpointContext(cpID, file)
568-
checkpoints[cpCtx.CheckpointID] = cpCtx
569-
candidates = append(candidates, cpCtx)
556+
if checkpointID == "" {
557+
return fmt.Sprintf("%s. Run: %s.", reason, suggestCheckpointFetchCommand(ctx))
570558
}
571-
preferred := preferredAttributionCandidate(candidates, file)
572-
applyPreferredToLine(line, preferred)
573-
line.Candidates = candidates
574-
line.Authorship = authorshipForPreferred(preferred)
575-
line.Tag = attributionTag(line.Authorship)
576-
return nil
559+
return fmt.Sprintf("%s. Run: %s. Then re-run entire checkpoint explain %s.", reason, suggestCheckpointFetchCommand(ctx), checkpointID)
577560
}
578561

579562
func (r *attributionResolver) fetchCheckpointContext(cpID id.CheckpointID, file string) (attributionCheckpointContext, error) {
@@ -1137,7 +1120,11 @@ func renderAttributionLineWhy(w io.Writer, file string, line attributionLine) {
11371120
fmt.Fprintf(w, " %s %q\n", sty.render(sty.bold, "Intent:"), stringutil.TruncateRunes(stringutil.CollapseWhitespace(line.Intent), 160, "..."))
11381121
}
11391122
if line.MetadataMissing {
1140-
fmt.Fprintf(w, " %s\n", sty.render(sty.yellow, "Checkpoint metadata was not found locally; showing trailer-level attribution only."))
1123+
message := "Checkpoint metadata was not found locally; showing trailer-level attribution only."
1124+
if line.MetadataMissingReason != "" {
1125+
message = line.MetadataMissingReason
1126+
}
1127+
fmt.Fprintf(w, " %s\n", sty.render(sty.yellow, message))
11411128
}
11421129
if line.SessionFallback {
11431130
fmt.Fprintf(w, " %s\n", sty.render(sty.yellow, "This file is not in the checkpoint session's recorded paths (it may have been renamed); the agent and prompt shown are a best-effort guess, not necessarily the session that produced this line."))
@@ -1201,6 +1188,13 @@ func renderAttributionFileWhy(w io.Writer, result *fileAttributionResult) {
12011188
if ctx.Prompt != "" {
12021189
fmt.Fprintf(w, " %s %q", sty.render(sty.dim, "·"), stringutil.TruncateRunes(stringutil.CollapseWhitespace(ctx.Prompt), 90, "..."))
12031190
}
1191+
if ctx.MetadataMissing {
1192+
message := "Checkpoint metadata was not found locally."
1193+
if ctx.MetadataMissingReason != "" {
1194+
message = ctx.MetadataMissingReason
1195+
}
1196+
fmt.Fprintf(w, "\n %s %s", sty.render(sty.yellow, "metadata missing:"), message)
1197+
}
12041198
fmt.Fprintln(w)
12051199
}
12061200
fmt.Fprintf(w, "\n %s\n\n", sty.render(sty.dim, "Tip: entire why "+result.File+":<line> shows the prompt behind a specific line."))
@@ -1301,6 +1295,7 @@ func applyPreferredToLine(line *attributionLine, preferred *attributionCandidate
13011295
line.Prompt = preferred.Prompt
13021296
line.Intent = preferred.Intent
13031297
line.MetadataMissing = preferred.MetadataMissing
1298+
line.MetadataMissingReason = preferred.MetadataMissingReason
13041299
line.SessionFallback = preferred.SessionFallback
13051300
line.PromptSessionLevel = preferred.PromptSessionLevel
13061301
}

‎cmd/entire/cli/attribution_test.go‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"os"
910
"os/exec"
@@ -385,12 +386,40 @@ func TestAttributionResolverUsesCheckpointReader(t *testing.T) {
385386
require.Equal(t, "Explain the authentication change.", ctx.Prompt)
386387
}
387388

389+
func TestAttributionResolverMissingMetadataIncludesReason(t *testing.T) {
390+
newAttributionRepo(t)
391+
392+
cpID := checkpointid.MustCheckpointID("cab2c3d4e5f6")
393+
stubReader := &attributionCheckpointReaderStub{
394+
readErr: errors.New("checkpoint summary unavailable"),
395+
}
396+
resolver := &attributionResolver{
397+
ctx: context.Background(),
398+
store: stubReader,
399+
fetchOnMiss: true,
400+
checkpointCache: make(map[string]attributionCheckpointContext),
401+
}
402+
403+
ctx := resolver.readCheckpointContext(cpID, "auth.py")
404+
require.True(t, ctx.MetadataMissing)
405+
require.Contains(t, ctx.MetadataMissingReason, "checkpoint summary unavailable")
406+
// "remote refresh failed" confirms fetch-on-miss was attempted.
407+
require.Contains(t, ctx.MetadataMissingReason, "remote refresh failed")
408+
require.Contains(t, ctx.MetadataMissingReason, "git fetch ")
409+
require.Contains(t, ctx.MetadataMissingReason, "entire/checkpoints/v1:entire/checkpoints/v1")
410+
require.Contains(t, ctx.MetadataMissingReason, "entire checkpoint explain cab2c3d4e5f6")
411+
}
412+
388413
type attributionCheckpointReaderStub struct {
389414
summary *checkpoint.CheckpointSummary
390415
content *checkpoint.SessionContent
416+
readErr error
391417
}
392418

393419
func (s *attributionCheckpointReaderStub) Read(context.Context, checkpointid.CheckpointID) (*checkpoint.CheckpointSummary, error) {
420+
if s.readErr != nil {
421+
return nil, s.readErr
422+
}
394423
return s.summary, nil
395424
}
396425

@@ -519,6 +548,93 @@ func TestAttributionWhyPreservesLineIndentation(t *testing.T) {
519548
require.Contains(t, out.String(), " return True")
520549
}
521550

551+
func TestAttributionWhyLineJSONShowsMissingMetadataReason(t *testing.T) {
552+
repoRoot := newAttributionRepo(t)
553+
testutil.WriteFile(t, repoRoot, "auth.py", "human_line = 1\nmissing_line = 2\n")
554+
testutil.GitAdd(t, repoRoot, "auth.py")
555+
testutil.GitCommit(t, repoRoot, trailers.FormatCheckpoint("missing metadata", checkpointid.MustCheckpointID("fab2c3d4e5f6")))
556+
557+
var out bytes.Buffer
558+
require.NoError(t, runAttributionWhy(context.Background(), &out, "auth.py:2", true))
559+
560+
var payload struct {
561+
File string `json:"file"`
562+
Line attributionLine `json:"line"`
563+
Checkpoints map[string]attributionCheckpointContext `json:"checkpoints,omitempty"`
564+
}
565+
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
566+
require.Equal(t, "auth.py", payload.File)
567+
require.True(t, payload.Line.MetadataMissing)
568+
require.Contains(t, payload.Line.MetadataMissingReason, "entire checkpoint explain fab2c3d4e5f6")
569+
require.Contains(t, payload.Line.MetadataMissingReason, "git fetch ")
570+
require.Contains(t, payload.Line.MetadataMissingReason, "entire/checkpoints/v1:entire/checkpoints/v1")
571+
checkpointCtx := payload.Checkpoints["fab2c3d4e5f6"]
572+
require.True(t, checkpointCtx.MetadataMissing)
573+
require.Equal(t, payload.Line.MetadataMissingReason, checkpointCtx.MetadataMissingReason)
574+
}
575+
576+
func TestAttributionWhyFileJSONShowsMissingMetadataReason(t *testing.T) {
577+
repoRoot := newAttributionRepo(t)
578+
testutil.WriteFile(t, repoRoot, "auth.py", "human_line = 1\nmissing_line = 2\n")
579+
testutil.GitAdd(t, repoRoot, "auth.py")
580+
testutil.GitCommit(t, repoRoot, trailers.FormatCheckpoint("missing metadata", checkpointid.MustCheckpointID("eab2c3d4e5f6")))
581+
582+
var out bytes.Buffer
583+
require.NoError(t, runAttributionWhy(context.Background(), &out, "auth.py", true))
584+
585+
var payload fileAttributionResult
586+
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
587+
checkpointCtx := payload.Checkpoints["eab2c3d4e5f6"]
588+
require.True(t, checkpointCtx.MetadataMissing)
589+
require.Contains(t, checkpointCtx.MetadataMissingReason, "entire checkpoint explain eab2c3d4e5f6")
590+
}
591+
592+
func TestAttributionWhyFileJSONLocalMetadataHasNoMissingReason(t *testing.T) {
593+
repoRoot := newAttributionRepo(t)
594+
writeAttributionCheckpoint(t, repoRoot, "dab2c3d4e5f6", checkpoint.WriteOptions{
595+
SessionID: "session-why-file-12345678",
596+
Prompts: []string{"Add a line with local checkpoint metadata."},
597+
FilesTouched: []string{"auth.py"},
598+
Agent: agent.AgentTypeClaudeCode,
599+
CheckpointsCount: 1,
600+
})
601+
testutil.WriteFile(t, repoRoot, "auth.py", "human_line = 1\nwhy_line = 2\n")
602+
testutil.GitAdd(t, repoRoot, "auth.py")
603+
testutil.GitCommit(t, repoRoot, trailers.FormatCheckpoint("local metadata", checkpointid.MustCheckpointID("dab2c3d4e5f6")))
604+
605+
var out bytes.Buffer
606+
require.NoError(t, runAttributionWhy(context.Background(), &out, "auth.py", true))
607+
608+
var payload fileAttributionResult
609+
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
610+
checkpointCtx := payload.Checkpoints["dab2c3d4e5f6"]
611+
require.False(t, checkpointCtx.MetadataMissing)
612+
require.Empty(t, checkpointCtx.MetadataMissingReason)
613+
}
614+
615+
func TestAttributionWhySuccessiveCallsKeepCheckpointMapStable(t *testing.T) {
616+
repoRoot := newAttributionRepo(t)
617+
testutil.WriteFile(t, repoRoot, "auth.py", "human_line = 1\nmissing_line = 2\n")
618+
testutil.GitAdd(t, repoRoot, "auth.py")
619+
testutil.GitCommit(t, repoRoot, trailers.FormatCheckpoint("missing metadata", checkpointid.MustCheckpointID("bab2c3d4e5f6")))
620+
621+
var lineOut bytes.Buffer
622+
require.NoError(t, runAttributionWhy(context.Background(), &lineOut, "auth.py:2", true))
623+
require.Contains(t, lineOut.String(), "bab2c3d4e5f6")
624+
625+
var firstOut bytes.Buffer
626+
require.NoError(t, runAttributionWhy(context.Background(), &firstOut, "auth.py", true))
627+
var firstPayload fileAttributionResult
628+
require.NoError(t, json.Unmarshal(firstOut.Bytes(), &firstPayload))
629+
630+
var secondOut bytes.Buffer
631+
require.NoError(t, runAttributionWhy(context.Background(), &secondOut, "auth.py", true))
632+
var secondPayload fileAttributionResult
633+
require.NoError(t, json.Unmarshal(secondOut.Bytes(), &secondPayload))
634+
635+
require.Equal(t, firstPayload.Checkpoints, secondPayload.Checkpoints)
636+
}
637+
522638
func newAttributionRepo(t *testing.T) string {
523639
t.Helper()
524640
repoRoot := t.TempDir()

0 commit comments

Comments
 (0)