Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions cmd/entire/cli/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,25 @@ func runAttachSurfaceReviewErrors(cmd *cobra.Command, sessionID string, agentNam
return err
}

// attachStepCount returns the displayed "steps" count for an attached session:
// the number of user prompts (turns) in the attached transcript, as counted by
// extractTranscriptMetadata. Floored at 1 so it never renders as "0 steps" for an
// empty/unparseable transcript. SaveStepCount stays 0 (no SaveStep ran), keeping
// the combined-attribution gate conservative for this fallback session.
func attachStepCount(turnCount int) int {
return max(turnCount, 1)
}

// attachPrompts returns the prompts recorded on an attached checkpoint. Attach
// records only the first user prompt (used for the display title); the full
// per-turn list isn't reconstructed for post-hoc imports.
func attachPrompts(meta transcriptMetadata) []string {
if meta.FirstPrompt == "" {
return nil
}
return []string{meta.FirstPrompt}
}

func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName types.AgentName, opts attachOptions) error {
// Initialize structured logger so logging.Warn/Info write to .entire/logs/ not stderr.
if err := logging.Init(ctx, sessionID); err != nil {
Expand Down Expand Up @@ -280,11 +299,6 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
return fmt.Errorf("failed to get git author: %w", err)
}

var prompts []string
if meta.FirstPrompt != "" {
prompts = []string{meta.FirstPrompt}
}

tokenUsage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, "")

_, redactSpan := perf.Start(ctx, "redact_transcript")
Expand All @@ -295,16 +309,17 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
}

writeOpts := cpkg.WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: sessionID,
Strategy: strategy.StrategyNameManualCommit,
Transcript: redactedTranscript,
Prompts: prompts,
AuthorName: author.Name,
AuthorEmail: author.Email,
Agent: ag.Type(),
Model: meta.Model,
TokenUsage: tokenUsage,
CheckpointID: checkpointID,
SessionID: sessionID,
Strategy: strategy.StrategyNameManualCommit,
Transcript: redactedTranscript,
Prompts: attachPrompts(meta),
CheckpointsCount: attachStepCount(meta.TurnCount),
AuthorName: author.Name,
AuthorEmail: author.Email,
Agent: ag.Type(),
Model: meta.Model,
TokenUsage: tokenUsage,
}
if opts.Review {
writeOpts.Kind = string(session.KindAgentReview)
Expand Down
21 changes: 18 additions & 3 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,17 @@ type WriteCommittedOptions struct {
// FilesTouched are files modified during the session
FilesTouched []string

// CheckpointsCount is the number of checkpoints in this session
// CheckpointsCount is the displayed "steps" count for this session: the number
// of user prompts attributed to this checkpoint (floored at 1). Despite the
// historical name/JSON tag, it is no longer a count of checkpoints.
CheckpointsCount int

// SaveStepCount is the number of SaveStep-recorded steps (shadow-branch
// commits) for this session. Distinct from CheckpointsCount (the displayed
// prompt count): this is the honest "did real checkpoint work happen" signal
// used to gate combined attribution. 0 means a commit-only / fallback session.
SaveStepCount int

// EphemeralBranch is the shadow branch name (for manual-commit strategy)
EphemeralBranch string

Expand Down Expand Up @@ -417,7 +425,9 @@ type CommittedInfo struct {
// CreatedAt is when the checkpoint was created
CreatedAt time.Time

// CheckpointsCount is the total number of checkpoints across all sessions
// CheckpointsCount is the aggregate displayed "steps" count across sessions:
// the sum of per-session prompt-window counts. Despite the historical name,
// it is not a count of checkpoint records.
CheckpointsCount int

// FilesTouched are files modified during all sessions
Expand Down Expand Up @@ -465,7 +475,12 @@ type CommittedMetadata struct {
CreatedAt time.Time `json:"created_at"`
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
// SaveStepCount is the number of SaveStep-recorded steps for this session.
// Honest "real checkpoint work happened" signal (0 = commit-only/fallback
// session), kept separate from the displayed CheckpointsCount prompt count.
// Added after CheckpointsCount stopped being a reliable did-SaveStep-run signal.
SaveStepCount int `json:"save_step_count,omitempty"`
FilesTouched []string `json:"files_touched"`

// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
Agent types.AgentType `json:"agent,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
CreatedAt: checkpointCreatedAt(opts),
Branch: opts.Branch,
CheckpointsCount: opts.CheckpointsCount,
SaveStepCount: opts.SaveStepCount,
FilesTouched: opts.FilesTouched,
Agent: opts.Agent,
Model: opts.Model,
Expand Down
12 changes: 12 additions & 0 deletions cmd/entire/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -1113,13 +1113,25 @@ func persistEventMetadataToState(event *agent.Event, state *strategy.SessionStat
}
// Use hook-reported turn count if available (take max); otherwise
// increment on each TurnEnd event to count turns ourselves.
prevTurnCount := state.SessionTurnCount
if event.TurnCount > 0 {
if event.TurnCount > state.SessionTurnCount {
state.SessionTurnCount = event.TurnCount
}
} else if event.Type == agent.TurnEnd {
state.SessionTurnCount++
}
// Deferred checkpoint-window reset: the first time the turn count actually
// advances after a checkpoint was written, re-anchor the window base to the
// count from before this turn so the current turn becomes the first prompt of
// the new window. Gate on a real advance (not just a TurnEnd / non-zero
// TurnCount) so a repeated or stale hook reporting the same cumulative count
// doesn't re-anchor early — that would make a later back-to-back checkpoint
// report 1 instead of matching the prior count.
if state.SessionTurnCount > prevTurnCount && state.PromptWindowResetPending {
state.PromptWindowBase = prevTurnCount
state.PromptWindowResetPending = false
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale TurnEnd clears window reset

Medium Severity

After a checkpoint, PromptWindowResetPending is cleared whenever a lifecycle event has TurnEnd or TurnCount > 0, even if SessionTurnCount did not increase. A repeated Cursor Stop hook with the same cumulative loop count can re-anchor PromptWindowBase early, so a later back-to-back checkpoint shows 1 step instead of matching the prior count.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 615839a. Configure here.

}
if event.ContextTokens > 0 {
state.ContextTokens = event.ContextTokens
}
Expand Down
106 changes: 106 additions & 0 deletions cmd/entire/cli/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1959,3 +1959,109 @@ func TestAdoptInvestigateEnv_RejectsBadRunID(t *testing.T) {
})
}
}

// promptWindow mirrors strategy.checkpointStepCount (unexported there): the
// displayed step count = SessionTurnCount - PromptWindowBase, floored at 1.
func promptWindow(s *strategy.SessionState) int {
if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 {
return w
}
return 1
}

// writeCheckpoint simulates what CondenseSession does to the window state: read
// the count, then set the deferred-reset flag (without zeroing the window).
func writeCheckpoint(s *strategy.SessionState) int {
n := promptWindow(s)
s.PromptWindowResetPending = true
return n
}

// TestPromptWindowDeferredReset exercises the two product-required examples:
// (1) p1,p2,p3 -> A=3 then p4,p5 -> C=2, and (2) two checkpoints with no prompt
// in between report the same count (deferred reset).
func TestPromptWindowDeferredReset(t *testing.T) {
turn := func(s *strategy.SessionState) {
persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd}, s)
}

s := &strategy.SessionState{}

// p1,p2,p3 -> checkpoint A => 3
turn(s)
turn(s)
turn(s)
if got := writeCheckpoint(s); got != 3 {
t.Fatalf("checkpoint A = %d, want 3", got)
}

// Back-to-back: checkpoint B with no prompt in between => same as A (3), not 0.
if got := writeCheckpoint(s); got != 3 {
t.Fatalf("back-to-back checkpoint B = %d, want 3", got)
}

// The next prompt re-anchors the window to start fresh.
turn(s) // p4: first prompt of the new window
if s.PromptWindowResetPending {
t.Fatalf("ResetPending should be cleared after the first post-checkpoint turn")
}
if s.PromptWindowBase != 3 {
t.Fatalf("PromptWindowBase = %d, want 3 (re-anchored to pre-turn count)", s.PromptWindowBase)
}
turn(s) // p5
if got := writeCheckpoint(s); got != 2 {
t.Fatalf("checkpoint C = %d, want 2", got)
}
}

// TestPromptWindowExecModeCumulativeTurnCount verifies the window derives
// correctly when turns arrive as a cumulative hook-reported TurnCount (exec-mode
// agents that never fire UserPromptSubmit/TurnStart), rather than as self-counted
// TurnEnd increments.
func TestPromptWindowExecModeCumulativeTurnCount(t *testing.T) {
exec := func(s *strategy.SessionState, cumulative int) {
persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd, TurnCount: cumulative}, s)
}

s := &strategy.SessionState{}

exec(s, 1)
exec(s, 2)
exec(s, 3)
if got := writeCheckpoint(s); got != 3 {
t.Fatalf("exec checkpoint A = %d, want 3", got)
}

exec(s, 4) // re-anchors base to 3
exec(s, 5)
if got := writeCheckpoint(s); got != 2 {
t.Fatalf("exec checkpoint B = %d, want 2", got)
}
}

// TestPromptWindowStaleHookDoesNotResetEarly guards against a repeated/stale hook
// (same cumulative TurnCount, so the count doesn't actually advance) clearing the
// deferred reset early. If it did, a later back-to-back checkpoint would report 1
// instead of matching the prior checkpoint's count.
func TestPromptWindowStaleHookDoesNotResetEarly(t *testing.T) {
exec := func(s *strategy.SessionState, cumulative int) {
persistEventMetadataToState(&agent.Event{Type: agent.TurnEnd, TurnCount: cumulative}, s)
}

s := &strategy.SessionState{}
exec(s, 1)
exec(s, 2)
exec(s, 3)
if got := writeCheckpoint(s); got != 3 {
t.Fatalf("checkpoint A = %d, want 3", got)
}

// Stale hook: same cumulative count, no real advance — must not re-anchor.
exec(s, 3)
if !s.PromptWindowResetPending {
t.Fatalf("stale hook should not clear ResetPending")
}
if got := writeCheckpoint(s); got != 3 {
t.Fatalf("back-to-back checkpoint B after stale hook = %d, want 3", got)
}
}
14 changes: 14 additions & 0 deletions cmd/entire/cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,20 @@ type State struct {
ContextTokens int `json:"context_tokens,omitempty"`
ContextWindowSize int `json:"context_window_size,omitempty"`

// PromptWindowBase is the SessionTurnCount value at the start of the current
// checkpoint window. The number of prompts attributed to the next checkpoint is
// SessionTurnCount - PromptWindowBase (floored at 1 when written). It is only
// advanced (deferred reset) the next time a turn is counted after a checkpoint
// was written, so two checkpoints with no prompt between them report the same
// count. Zero-value safe on old state files: base 0 ⇒ window = SessionTurnCount,
// i.e. "all prompts so far" (correct first-checkpoint semantics).
PromptWindowBase int `json:"prompt_window_base,omitempty"`

// PromptWindowResetPending indicates a checkpoint was just written and the
// window base must be re-anchored to the current SessionTurnCount the next time
// a turn is counted. Deferred so back-to-back checkpoints share a count.
PromptWindowResetPending bool `json:"prompt_window_reset_pending,omitempty"`

// Deprecated: TranscriptLinesAtStart is replaced by CheckpointTranscriptStart.
// Kept for backward compatibility with existing state files.
TranscriptLinesAtStart int `json:"transcript_lines_at_start,omitempty"`
Expand Down
25 changes: 23 additions & 2 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ type condenseOpts struct {

var redactSessionJSONLBytes = redact.JSONLBytes

// checkpointStepCount returns the number of user prompts attributed to the
// checkpoint being written: the turns counted since the current window's base.
// The base is re-anchored (deferred) the next time a turn is counted after a
// checkpoint write, so back-to-back checkpoints with no prompt between them share
// a count. Floored at 1 so we never record 0 (covers a fast-path checkpoint
// before any turn, and exec-mode gaps where turns weren't counted). Attach has
// its own count (see attachStepCount); it does not go through this path.
func checkpointStepCount(s *SessionState) int {
if w := s.SessionTurnCount - s.PromptWindowBase; w >= 1 {
return w
}
return 1
}

// CondenseSession condenses a session's shadow branch to permanent storage.
// checkpointID is the 12-hex-char value from the Entire-Checkpoint trailer.
// Metadata is stored at sharded path: <checkpoint_id[:2]>/<checkpoint_id[2:]>/
Expand Down Expand Up @@ -227,7 +241,8 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
Transcript: redactedTranscript,
Prompts: sessionData.Prompts,
FilesTouched: sessionData.FilesTouched,
CheckpointsCount: state.StepCount,
CheckpointsCount: checkpointStepCount(state),
SaveStepCount: state.StepCount,
EphemeralBranch: shadowBranchName,
AuthorName: authorName,
AuthorEmail: authorEmail,
Expand Down Expand Up @@ -261,6 +276,12 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
writeCommittedSpan.End()
writeV1Duration := time.Since(writeV1Start)

// Deferred prompt-window reset: a checkpoint was written, so the window base
// must be re-anchored — but not now. We defer until the next counted turn (in
// persistEventMetadataToState) so two checkpoints with no prompt between them
// report the same count instead of the second showing 0.
state.PromptWindowResetPending = true

// Mirror the committed write to the v1 custom ref when opted in
// (local-only, never pushed; failures are logged, not fatal).
MirrorCommittedMetadataRefBestEffort(ctx, repo)
Expand All @@ -280,7 +301,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re
return &CondenseResult{
CheckpointID: checkpointID,
SessionID: state.SessionID,
CheckpointsCount: state.StepCount,
CheckpointsCount: checkpointStepCount(state),
FilesTouched: sessionData.FilesTouched,
Prompts: sessionData.Prompts,
TotalTranscriptLines: sessionData.FullTranscriptLines,
Expand Down
31 changes: 31 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,34 @@ func TestCondenseSession_TagsCheckpointSummaryWithHasInvestigation(t *testing.T)
require.Equal(t, "0123456789ab", meta.InvestigateRunID, "per-session InvestigateRunID")
require.Equal(t, "Why is checkout flaky?", meta.InvestigateTopic, "per-session InvestigateTopic")
}

// TestCheckpointStepCount covers the prompt-window math that produces the
// displayed "steps" count: SessionTurnCount - PromptWindowBase, floored at 1.
func TestCheckpointStepCount(t *testing.T) {
tests := []struct {
name string
sessionTurnCount int
promptWindowBase int
want int
}{
{"first window of three prompts", 3, 0, 3},
{"second window of two prompts", 5, 3, 2},
{"no turns counted floors to 1", 0, 0, 1},
// Back-to-back checkpoint: base not yet re-anchored, so it reports the same
// count as the prior checkpoint rather than 0.
{"back-to-back reports same as prior", 3, 0, 3},
{"empty window floors to 1", 3, 3, 1},
{"negative guard floors to 1", 2, 5, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SessionState{
SessionTurnCount: tt.sessionTurnCount,
PromptWindowBase: tt.promptWindowBase,
}
if got := checkpointStepCount(s); got != tt.want {
t.Errorf("checkpointStepCount() = %d, want %d", got, tt.want)
}
})
}
}
7 changes: 5 additions & 2 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1072,15 +1072,18 @@ func (s *ManualCommitStrategy) updateCombinedAttributionForCheckpoint(
}

// Collect union of files_touched from sessions that had real checkpoints (SaveStep ran).
// Sessions with checkpoints_count == 0 (e.g., commit-only sessions) use a fallback that
// Sessions with no SaveStep steps (e.g., commit-only sessions) use a fallback that
// includes ALL committed files, which would incorrectly classify human-created files as agent work.
// Gate on SaveStepCount (the honest "SaveStep ran" signal), not CheckpointsCount —
// CheckpointsCount is now a prompt count floored at 1, so it's no longer 0 for these sessions.
// Old metadata lacks SaveStepCount → 0 → conservatively skipped, matching prior behavior.
agentFiles := make(map[string]struct{})
for i := range len(summary.Sessions) {
metadata, readErr := store.ReadSessionMetadata(ctx, checkpointID, i)
if readErr != nil || metadata == nil {
continue
}
if metadata.CheckpointsCount == 0 {
if metadata.SaveStepCount == 0 {
continue // Skip sessions that used the filesTouched fallback
}
for _, f := range metadata.FilesTouched {
Expand Down
3 changes: 3 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3558,6 +3558,9 @@ func TestCondenseSession_GeminiMultiCheckpoint(t *testing.T) {
// what would happen after condensing checkpoint 1
state.CheckpointTranscriptStart = 2 // Start from message index 2 (the second user prompt)
state.StepCount = 1 // Set to 1 (will be incremented to 2 by SaveStep)
// CheckpointsCount is now the prompt window (SessionTurnCount - PromptWindowBase),
// not StepCount. Simulate two counted turns so the assertion below still expects 2.
state.SessionTurnCount = 2
if err := s.saveSessionState(context.Background(), state); err != nil {
t.Fatalf("failed to update session state: %v", err)
}
Expand Down
Loading
Loading