Skip to content

Count a turn's step and tokens once when its Stop repeats - #2779

Open
peyton-alt wants to merge 3 commits into
mainfrom
peyton/turn-end-once
Open

peyton-alt wants to merge 3 commits into
mainfrom
peyton/turn-end-once

Conversation

@peyton-alt

@peyton-alt peyton-alt commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1576

Bug

A Stop for a file-changing turn can reach Entire more than once: a retried hook, a duplicate delivery, or two hook processes racing. Each repeat runs SaveStep again. It adds the turn's tokens to TokenUsage and CheckpointTokenUsage again, and increments StepCount again.

Measured with the real hooks before this fix:

Agent Session total Pending window StepCount Committed checkpoint
Claude Code (turn 100/10) 200/20 200/20 2 100/10 (correct)
Cursor (turn 200/50) 400/100 400/100 2 400/100
  • Claude Code: the committed checkpoint is right because condensation recounts it from the transcript. The session total stays doubled.
  • Cursor: the hook tokens double end to end, including the committed checkpoint.
  • Git: no extra commit is written, because SaveStep writes no git objects.

Fix

SaveStep records which turn its last step came from, in the same locked mutation that adds the tokens and the step:

  • A repeat is a Stop that read a transcript of the same byte size as the recorded step. A new turn grows the transcript. This covers a repeat whose pre-prompt state an earlier Stop already removed, and a turn that never had a TurnStart.
  • A Stop whose pre-prompt nonce (PrePromptState.TurnNonce) differs from the recorded one is always a new turn.
    • TurnStart writes the nonce before it takes any session lock, so a TurnStart that times out on the lock still gets a fresh one. Such a TurnStart never rotates TurnID, which is why this is not keyed on TurnID.
    • A matching nonce alone is not enough: a Stop that ran late can read the next turn's nonce from the per-session pre-prompt file.
  • A repeat still merges its files, but adds no tokens and no step.
    • Exception: if a commit condensed the window in between, the repeat counts one step, because its files are new pending work.

Legitimate paths preserved

  • Normal Stop after TurnStart: counts once.
  • First Stop after a mid-session enable, and turns with no TurnStart (Droid exec mode, a turn the agent starts on its own): each grows the transcript, so each still counts.
  • A TurnStart that hit the session-lock timeout: gets a new nonce, so its turn counts.
  • Antigravity's suppressed mid-turn TurnStart, mid-turn commits, and Ctrl-C recovery: unchanged.
  • Compaction, Droid Workers and attach: do not go through SaveStep.

Overlap with #2722

#2722 applies a turn's tokens at the turn-end transition when the turn changes no files, gated on the ACTIVE phase. This PR covers the file-changing path, so the two do not overlap in behaviour. They do conflict textually: this PR wraps SaveStep's token block, which #2722 extracts into applyTurnTokenUsage. Whichever merges second resolves that.

Follow-up after both land: #2722's no-change path should record the same marker. Otherwise a no-change Stop followed by a file-changing repeat counts the turn twice.

Remaining gaps, unchanged from main:

  • A Stop-hook continuation in the same turn still counts its tokens from transcript line 0.
  • A late duplicate that reads the next turn's pre-prompt state is not caught.

Tests

  • Integration, with real hooks:
    • Claude Code: a repeated Stop, concurrent Stops, a next turn after the repeat, Stops without TurnStart (plus a repeat of one), and an unprompted turn after a prompted one.
    • Cursor: a repeated Stop and concurrent Stops, both checked through to the committed checkpoint.
  • Unit (SaveStep):
    • a repeated nonce;
    • a matching nonce over a grown transcript (a late Stop holding the next turn's nonce) still counts;
    • a new nonce while TurnID stays unrotated;
    • matching on transcript size when there is no nonce;
    • turn identity unknown;
    • a repeat after condensation counts a step but no tokens, and keeps TranscriptIdentifierAtStart.

🤖 Generated with Claude Code

A retried, duplicate, or concurrent Stop for a file-changing turn ran
SaveStep again. Each run added the turn's tokens to the session total and
the pending checkpoint window and incremented StepCount, so Cursor (whose
checkpoint uses the window) committed double tokens.

SaveStep now records which turn its last step came from, in the same
locked mutation that counts it. It records the turn's pre-prompt nonce,
which TurnStart writes before taking any session lock, so a TurnStart that
times out on the lock still gets a fresh one. It also records the
transcript size, for Stops whose pre-prompt state is already gone. A repeat
merges its files but adds no tokens and no step, unless a commit condensed
the window in between. Turns without a TurnStart grow the transcript, so
they still count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4Y330425V53XVKKH7KPKTG9
@peyton-alt
peyton-alt requested a review from a team as a code owner October 14, 2026 20:55
Copilot AI balanced review requested due to automatic review settings October 14, 2026 20:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Hook-bookkeeping transcript growth can bypass duplicate detection and double-count steps and tokens.

1 open finding
What changed in this PR

Prevents repeated Stop hooks from counting a file-changing turn’s steps and tokens more than once.

Changes:

  • Adds turn nonces and transcript-size fallback markers for duplicate detection.
  • Preserves file merging and pending steps after condensation.
  • Adds unit and integration coverage and documents accounting behavior.
File Description
docs/​architecture/​sessions-and-checkpoints.md Documents repeated-Stop accounting.
cmd/​entire/​cli/​strategy/​strategy.go Adds turn identity to step context.
cmd/​entire/​cli/​strategy/​save_step_repeat_test.go Tests repeat detection and condensation cases.
cmd/​entire/​cli/​strategy/​manual_commit_git.go Gates step and token accumulation on repeat detection.
cmd/​entire/​cli/​state.go Generates pre-prompt turn nonces.
cmd/​entire/​cli/​session/​state.go Persists the last recorded step’s markers.
cmd/​entire/​cli/​lifecycle.go Passes turn markers to SaveStep.
cmd/​entire/​cli/​integration_test/​turn_end_once_test.go Tests sequential and concurrent Stop hooks.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

if step.TurnNonce != "" {
return state.LastStepTurnNonce == step.TurnNonce
}
return step.TranscriptSize > 0 && state.LastStepTranscriptSize == step.TranscriptSize

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 7233e16. Configure here.

return state.LastStepTurnNonce == step.TurnNonce
}
return step.TranscriptSize > 0 && state.LastStepTranscriptSize == step.TranscriptSize
}

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 nonce drops later turns

Medium Severity

isRepeatedStep treats any Stop that still carries LastStepTurnNonce as a repeat and ignores transcript growth. handleLifecycleTurnEnd loads pre-prompt state long before CleanupPrePromptState, and cleanup failure is only a warning, so a later file-changing Stop without its own TurnStart can still see the previous turn's nonce. That path is then skipped for tokens and StepCount, including an unprompted follow-up turn the change is meant to keep counting.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7233e16. Configure here.

peyton-alt and others added 2 commits October 14, 2026 14:14
The pre-prompt file is per session. A Stop that ran late can read the next
turn's nonce, and a nonce match alone would then let it claim that turn and
drop the turn's own Stop. A differing nonce still always means a new turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entire-Checkpoint: 01M4Y58SKS865VHV7D4MER7SAS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants