Skip to content

Commit 615839a

Browse files
dipreeclaude
andcommitted
Count checkpoint "steps" by prompts, not file-modifying turns
The session "steps" shown in the UI came from the CLI's StepCount, which only incremented on turns that modified files and reset to 0 after each condensation. This produced wrong and frequently-zero counts. - Bug 1: checkpoints written before any SaveStep ran (mid-turn/commit-only condensations) recorded 0. - Bug 2: `entire attach` never set the count, so it always wrote 0. - Counting change: derive the count from SessionTurnCount (every prompt, exec-mode safe) over a per-checkpoint window, with a deferred reset so back-to-back checkpoints report the same count instead of 0. Floored at 1. Decouples the combined-attribution gate (previously keyed off the now-floored checkpoints_count) onto a new SaveStepCount metadata field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 06df606 commit 615839a

10 files changed

Lines changed: 196 additions & 15 deletions

File tree

cmd/entire/cli/attach.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ func runAttachSurfaceReviewErrors(cmd *cobra.Command, sessionID string, agentNam
150150
return err
151151
}
152152

153+
// attachStepCount returns the displayed "steps" count for an attached session.
154+
// Attach writes exactly one checkpoint and has at most meta.FirstPrompt, so this
155+
// floors at 1 — it must never render as "0 steps". SaveStepCount stays 0 (no
156+
// SaveStep ran), keeping the combined-attribution gate conservative for this
157+
// fallback session.
158+
func attachStepCount(prompts []string) int {
159+
return max(len(prompts), 1)
160+
}
161+
153162
func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, opts attachOptions) error {
154163
// Initialize structured logger so logging.Warn/Info write to .entire/logs/ not stderr.
155164
if err := logging.Init(ctx, sessionID); err != nil {
@@ -295,16 +304,17 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
295304
}
296305

297306
writeOpts := cpkg.WriteCommittedOptions{
298-
CheckpointID: checkpointID,
299-
SessionID: sessionID,
300-
Strategy: strategy.StrategyNameManualCommit,
301-
Transcript: redactedTranscript,
302-
Prompts: prompts,
303-
AuthorName: author.Name,
304-
AuthorEmail: author.Email,
305-
Agent: ag.Type(),
306-
Model: meta.Model,
307-
TokenUsage: tokenUsage,
307+
CheckpointID: checkpointID,
308+
SessionID: sessionID,
309+
Strategy: strategy.StrategyNameManualCommit,
310+
Transcript: redactedTranscript,
311+
Prompts: prompts,
312+
CheckpointsCount: attachStepCount(prompts),
313+
AuthorName: author.Name,
314+
AuthorEmail: author.Email,
315+
Agent: ag.Type(),
316+
Model: meta.Model,
317+
TokenUsage: tokenUsage,
308318
}
309319
if opts.Review {
310320
writeOpts.Kind = string(session.KindAgentReview)

cmd/entire/cli/checkpoint/checkpoint.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,12 @@ type WriteCommittedOptions struct {
230230
// CheckpointsCount is the number of checkpoints in this session
231231
CheckpointsCount int
232232

233+
// SaveStepCount is the number of SaveStep-recorded steps (shadow-branch
234+
// commits) for this session. Distinct from CheckpointsCount (the displayed
235+
// prompt count): this is the honest "did real checkpoint work happen" signal
236+
// used to gate combined attribution. 0 means a commit-only / fallback session.
237+
SaveStepCount int
238+
233239
// EphemeralBranch is the shadow branch name (for manual-commit strategy)
234240
EphemeralBranch string
235241

@@ -465,7 +471,12 @@ type CommittedMetadata struct {
465471
CreatedAt time.Time `json:"created_at"`
466472
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
467473
CheckpointsCount int `json:"checkpoints_count"`
468-
FilesTouched []string `json:"files_touched"`
474+
// SaveStepCount is the number of SaveStep-recorded steps for this session.
475+
// Honest "real checkpoint work happened" signal (0 = commit-only/fallback
476+
// session), kept separate from the displayed CheckpointsCount prompt count.
477+
// Added after CheckpointsCount stopped being a reliable did-SaveStep-run signal.
478+
SaveStepCount int `json:"save_step_count,omitempty"`
479+
FilesTouched []string `json:"files_touched"`
469480

470481
// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
471482
Agent types.AgentType `json:"agent,omitempty"`

cmd/entire/cli/checkpoint/committed.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
441441
CreatedAt: checkpointCreatedAt(opts),
442442
Branch: opts.Branch,
443443
CheckpointsCount: opts.CheckpointsCount,
444+
SaveStepCount: opts.SaveStepCount,
444445
FilesTouched: opts.FilesTouched,
445446
Agent: opts.Agent,
446447
Model: opts.Model,

cmd/entire/cli/lifecycle.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,13 +1113,22 @@ func persistEventMetadataToState(event *agent.Event, state *strategy.SessionStat
11131113
}
11141114
// Use hook-reported turn count if available (take max); otherwise
11151115
// increment on each TurnEnd event to count turns ourselves.
1116+
prevTurnCount := state.SessionTurnCount
11161117
if event.TurnCount > 0 {
11171118
if event.TurnCount > state.SessionTurnCount {
11181119
state.SessionTurnCount = event.TurnCount
11191120
}
11201121
} else if event.Type == agent.TurnEnd {
11211122
state.SessionTurnCount++
11221123
}
1124+
// Deferred checkpoint-window reset: the first time a turn is counted after a
1125+
// checkpoint was written, re-anchor the window base to the turn count from
1126+
// before this turn so the current turn becomes the first prompt of the new
1127+
// window. Until then, back-to-back checkpoints keep reporting the same count.
1128+
if (event.TurnCount > 0 || event.Type == agent.TurnEnd) && state.PromptWindowResetPending {
1129+
state.PromptWindowBase = prevTurnCount
1130+
state.PromptWindowResetPending = false
1131+
}
11231132
if event.ContextTokens > 0 {
11241133
state.ContextTokens = event.ContextTokens
11251134
}

cmd/entire/cli/lifecycle_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1959,3 +1959,82 @@ func TestAdoptInvestigateEnv_RejectsBadRunID(t *testing.T) {
19591959
})
19601960
}
19611961
}
1962+
1963+
// promptWindow mirrors strategy.checkpointStepCount (unexported there): the
1964+
// displayed step count = SessionTurnCount - PromptWindowBase, floored at 1.
1965+
func promptWindow(s *strategy.SessionState) int {
1966+
if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 {
1967+
return w
1968+
}
1969+
return 1
1970+
}
1971+
1972+
// writeCheckpoint simulates what CondenseSession does to the window state: read
1973+
// the count, then set the deferred-reset flag (without zeroing the window).
1974+
func writeCheckpoint(s *strategy.SessionState) int {
1975+
n := promptWindow(s)
1976+
s.PromptWindowResetPending = true
1977+
return n
1978+
}
1979+
1980+
// TestPromptWindowDeferredReset exercises the two product-required examples:
1981+
// (1) p1,p2,p3 -> A=3 then p4,p5 -> C=2, and (2) two checkpoints with no prompt
1982+
// in between report the same count (deferred reset).
1983+
func TestPromptWindowDeferredReset(t *testing.T) {
1984+
turn := func(s *strategy.SessionState) {
1985+
persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd}, s)
1986+
}
1987+
1988+
s := &strategy.SessionState{}
1989+
1990+
// p1,p2,p3 -> checkpoint A => 3
1991+
turn(s)
1992+
turn(s)
1993+
turn(s)
1994+
if got := writeCheckpoint(s); got != 3 {
1995+
t.Fatalf("checkpoint A = %d, want 3", got)
1996+
}
1997+
1998+
// Back-to-back: checkpoint B with no prompt in between => same as A (3), not 0.
1999+
if got := writeCheckpoint(s); got != 3 {
2000+
t.Fatalf("back-to-back checkpoint B = %d, want 3", got)
2001+
}
2002+
2003+
// The next prompt re-anchors the window to start fresh.
2004+
turn(s) // p4: first prompt of the new window
2005+
if s.PromptWindowResetPending {
2006+
t.Fatalf("ResetPending should be cleared after the first post-checkpoint turn")
2007+
}
2008+
if s.PromptWindowBase != 3 {
2009+
t.Fatalf("PromptWindowBase = %d, want 3 (re-anchored to pre-turn count)", s.PromptWindowBase)
2010+
}
2011+
turn(s) // p5
2012+
if got := writeCheckpoint(s); got != 2 {
2013+
t.Fatalf("checkpoint C = %d, want 2", got)
2014+
}
2015+
}
2016+
2017+
// TestPromptWindowExecModeCumulativeTurnCount verifies the window derives
2018+
// correctly when turns arrive as a cumulative hook-reported TurnCount (exec-mode
2019+
// agents that never fire UserPromptSubmit/TurnStart), rather than as self-counted
2020+
// TurnEnd increments.
2021+
func TestPromptWindowExecModeCumulativeTurnCount(t *testing.T) {
2022+
exec := func(s *strategy.SessionState, cumulative int) {
2023+
persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd, TurnCount: cumulative}, s)
2024+
}
2025+
2026+
s := &strategy.SessionState{}
2027+
2028+
exec(s, 1)
2029+
exec(s, 2)
2030+
exec(s, 3)
2031+
if got := writeCheckpoint(s); got != 3 {
2032+
t.Fatalf("exec checkpoint A = %d, want 3", got)
2033+
}
2034+
2035+
exec(s, 4) // re-anchors base to 3
2036+
exec(s, 5)
2037+
if got := writeCheckpoint(s); got != 2 {
2038+
t.Fatalf("exec checkpoint B = %d, want 2", got)
2039+
}
2040+
}

cmd/entire/cli/session/state.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ type State struct {
246246
ContextTokens int `json:"context_tokens,omitempty"`
247247
ContextWindowSize int `json:"context_window_size,omitempty"`
248248

249+
// PromptWindowBase is the SessionTurnCount value at the start of the current
250+
// checkpoint window. The number of prompts attributed to the next checkpoint is
251+
// SessionTurnCount - PromptWindowBase (floored at 1 when written). It is only
252+
// advanced (deferred reset) the next time a turn is counted after a checkpoint
253+
// was written, so two checkpoints with no prompt between them report the same
254+
// count. Zero-value safe on old state files: base 0 ⇒ window = SessionTurnCount,
255+
// i.e. "all prompts so far" (correct first-checkpoint semantics).
256+
PromptWindowBase int `json:"prompt_window_base,omitempty"`
257+
258+
// PromptWindowResetPending indicates a checkpoint was just written and the
259+
// window base must be re-anchored to the current SessionTurnCount the next time
260+
// a turn is counted. Deferred so back-to-back checkpoints share a count.
261+
PromptWindowResetPending bool `json:"prompt_window_reset_pending,omitempty"`
262+
249263
// Deprecated: TranscriptLinesAtStart is replaced by CheckpointTranscriptStart.
250264
// Kept for backward compatibility with existing state files.
251265
TranscriptLinesAtStart int `json:"transcript_lines_at_start,omitempty"`

cmd/entire/cli/strategy/manual_commit_condensation.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,19 @@ type condenseOpts struct {
101101

102102
var redactSessionJSONLBytes = redact.JSONLBytes
103103

104+
// checkpointStepCount returns the number of user prompts attributed to the
105+
// checkpoint being written: the turns counted since the current window's base.
106+
// The base is re-anchored (deferred) the next time a turn is counted after a
107+
// checkpoint write, so back-to-back checkpoints with no prompt between them share
108+
// a count. Floored at 1 so we never record 0 (covers attach, a fast-path
109+
// checkpoint before any turn, and exec-mode gaps where turns weren't counted).
110+
func checkpointStepCount(s *SessionState) int {
111+
if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 {
112+
return w
113+
}
114+
return 1
115+
}
116+
104117
// CondenseSession condenses a session's shadow branch to permanent storage.
105118
// checkpointID is the 12-hex-char value from the Entire-Checkpoint trailer.
106119
// Metadata is stored at sharded path: <checkpoint_id[:2]>/<checkpoint_id[2:]>/
@@ -227,7 +240,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
227240
Transcript: redactedTranscript,
228241
Prompts: sessionData.Prompts,
229242
FilesTouched: sessionData.FilesTouched,
230-
CheckpointsCount: state.StepCount,
243+
CheckpointsCount: checkpointStepCount(state),
244+
SaveStepCount: state.StepCount,
231245
EphemeralBranch: shadowBranchName,
232246
AuthorName: authorName,
233247
AuthorEmail: authorEmail,
@@ -261,6 +275,12 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
261275
writeCommittedSpan.End()
262276
writeV1Duration := time.Since(writeV1Start)
263277

278+
// Deferred prompt-window reset: a checkpoint was written, so the window base
279+
// must be re-anchored — but not now. We defer until the next counted turn (in
280+
// persistEventMetadataToState) so two checkpoints with no prompt between them
281+
// report the same count instead of the second showing 0.
282+
state.PromptWindowResetPending = true
283+
264284
// Mirror the committed write to the v1 custom ref when opted in
265285
// (local-only, never pushed; failures are logged, not fatal).
266286
mirrorMetadataToV1CustomRef(ctx, repo)
@@ -280,7 +300,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
280300
return &CondenseResult{
281301
CheckpointID: checkpointID,
282302
SessionID: state.SessionID,
283-
CheckpointsCount: state.StepCount,
303+
CheckpointsCount: checkpointStepCount(state),
284304
FilesTouched: sessionData.FilesTouched,
285305
Prompts: sessionData.Prompts,
286306
TotalTranscriptLines: sessionData.FullTranscriptLines,

cmd/entire/cli/strategy/manual_commit_condensation_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,3 +554,34 @@ func TestCondenseSession_TagsCheckpointSummaryWithHasInvestigation(t *testing.T)
554554
require.Equal(t, "0123456789ab", meta.InvestigateRunID, "per-session InvestigateRunID")
555555
require.Equal(t, "Why is checkout flaky?", meta.InvestigateTopic, "per-session InvestigateTopic")
556556
}
557+
558+
// TestCheckpointStepCount covers the prompt-window math that produces the
559+
// displayed "steps" count: SessionTurnCount - PromptWindowBase, floored at 1.
560+
func TestCheckpointStepCount(t *testing.T) {
561+
tests := []struct {
562+
name string
563+
sessionTurnCount int
564+
promptWindowBase int
565+
want int
566+
}{
567+
{"first window of three prompts", 3, 0, 3},
568+
{"second window of two prompts", 5, 3, 2},
569+
{"no turns counted floors to 1", 0, 0, 1},
570+
// Back-to-back checkpoint: base not yet re-anchored, so it reports the same
571+
// count as the prior checkpoint rather than 0.
572+
{"back-to-back reports same as prior", 3, 0, 3},
573+
{"empty window floors to 1", 3, 3, 1},
574+
{"negative guard floors to 1", 2, 5, 1},
575+
}
576+
for _, tt := range tests {
577+
t.Run(tt.name, func(t *testing.T) {
578+
s := &SessionState{
579+
SessionTurnCount: tt.sessionTurnCount,
580+
PromptWindowBase: tt.promptWindowBase,
581+
}
582+
if got := checkpointStepCount(s); got != tt.want {
583+
t.Errorf("checkpointStepCount() = %d, want %d", got, tt.want)
584+
}
585+
})
586+
}
587+
}

cmd/entire/cli/strategy/manual_commit_hooks.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,15 +1072,18 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(
10721072
}
10731073

10741074
// Collect union of files_touched from sessions that had real checkpoints (SaveStep ran).
1075-
// Sessions with checkpoints_count == 0 (e.g., commit-only sessions) use a fallback that
1075+
// Sessions with no SaveStep steps (e.g., commit-only sessions) use a fallback that
10761076
// includes ALL committed files, which would incorrectly classify human-created files as agent work.
1077+
// Gate on SaveStepCount (the honest "SaveStep ran" signal), not CheckpointsCount —
1078+
// CheckpointsCount is now a prompt count floored at 1, so it's no longer 0 for these sessions.
1079+
// Old metadata lacks SaveStepCount → 0 → conservatively skipped, matching prior behavior.
10771080
agentFiles := make(map[string]struct{})
10781081
for i := range len(summary.Sessions) {
10791082
metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i)
10801083
if readErr != nil || metadata == nil {
10811084
continue
10821085
}
1083-
if metadata.CheckpointsCount == 0 {
1086+
if metadata.SaveStepCount == 0 {
10841087
continue // Skip sessions that used the filesTouched fallback
10851088
}
10861089
for _, f := range metadata.FilesTouched {

cmd/entire/cli/strategy/manual_commit_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3558,6 +3558,9 @@ func TestCondenseSession_GeminiMultiCheckpoint(t *testing.T) {
35583558
// what would happen after condensing checkpoint 1
35593559
state.CheckpointTranscriptStart = 2 // Start from message index 2 (the second user prompt)
35603560
state.StepCount = 1 // Set to 1 (will be incremented to 2 by SaveStep)
3561+
// CheckpointsCount is now the prompt window (SessionTurnCount - PromptWindowBase),
3562+
// not StepCount. Simulate two counted turns so the assertion below still expects 2.
3563+
state.SessionTurnCount = 2
35613564
if err := s.saveSessionState(context.Background(), state); err != nil {
35623565
t.Fatalf("failed to update session state: %v", err)
35633566
}

0 commit comments

Comments
 (0)