From ff27ef324113b2c9aadb059bc87ea2a286b6cf9d Mon Sep 17 00:00:00 2001 From: Daniel Weinmann Date: Thu, 12 Feb 2026 08:46:18 -0300 Subject: [PATCH 1/3] Fix checkpoint data not pushed when agent commits and pushes in same turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an AI agent commits and pushes in the same turn, PostCommit defers condensation (ACTIVE → ACTIVE_COMMITTED) but PrePush has already pushed the stale entire/checkpoints/v1 branch. Condensation only runs at turn-end, after the push is long gone. Record the push remote in session state during PrePush, then push again at turn-end after condensation completes. Clear PendingPushRemote on new prompt start for Ctrl-C recovery. Ref #275 Entire-Checkpoint: adb31abc2a46 --- cmd/entire/cli/session/state.go | 6 + .../cli/strategy/manual_commit_hooks.go | 16 ++ cmd/entire/cli/strategy/manual_commit_push.go | 47 ++++- .../cli/strategy/manual_commit_push_test.go | 197 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 cmd/entire/cli/strategy/manual_commit_push_test.go diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 950b84c10..a8753a0b4 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -63,6 +63,12 @@ type State struct { // Generated once when first needed, reused across all commits in the session. PendingCheckpointID string `json:"pending_checkpoint_id,omitempty"` + // PendingPushRemote is the git remote name from a push that happened while + // condensation was deferred (ACTIVE_COMMITTED phase). After condensation + // completes at turn-end, entire/checkpoints/v1 is pushed to this remote. + // Cleared after push or on new prompt start. + PendingPushRemote string `json:"pending_push_remote,omitempty"` + // LastInteractionTime is updated on every hook invocation. // Used for stale session detection in "entire doctor". LastInteractionTime *time.Time `json:"last_interaction_time,omitempty"` diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index b5f927957..5196da30e 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1248,6 +1248,9 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age if state.PendingCheckpointID != "" { state.PendingCheckpointID = "" } + if state.PendingPushRemote != "" { + state.PendingPushRemote = "" + } // Calculate attribution at prompt start (BEFORE agent makes any changes) // This captures user edits since the last checkpoint (or base commit for first prompt). @@ -1533,6 +1536,19 @@ func (s *ManualCommitStrategy) handleTurnEndCondense(logCtx context.Context, sta s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) + // Push condensed checkpoint data if a push happened while condensation was deferred + if state.PendingPushRemote != "" { + remote := state.PendingPushRemote + state.PendingPushRemote = "" + logging.Info(logCtx, "turn-end: pushing condensed checkpoint data", + slog.String("remote", remote)) + if pushErr := pushSessionsBranchCommon(remote, paths.MetadataBranchName); pushErr != nil { + logging.Warn(logCtx, "turn-end: failed to push condensed checkpoint data", + slog.String("remote", remote), + slog.String("error", pushErr.Error())) + } + } + // Delete shadow branches after condensation — but only if no other active // sessions share the branch (same safety check PostCommit uses). for branchName := range shadowBranchesToDelete { diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 9694d238a..cc6ba4394 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -1,6 +1,13 @@ package strategy -import "github.com/entireio/cli/cmd/entire/cli/paths" +import ( + "context" + "log/slog" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" +) // PrePush is called by the git pre-push hook before pushing to a remote. // It pushes the entire/checkpoints/v1 branch alongside the user's push. @@ -9,5 +16,43 @@ import "github.com/entireio/cli/cmd/entire/cli/paths" // - "prompt" (default): ask user with option to enable auto // - "false"/"off"/"no": never push func (s *ManualCommitStrategy) PrePush(remote string) error { + s.recordPendingPushRemote(remote) return pushSessionsBranchCommon(remote, paths.MetadataBranchName) } + +// recordPendingPushRemote records the push remote on sessions that have deferred +// condensation (ACTIVE_COMMITTED with PendingCheckpointID). When condensation +// completes at turn-end, the metadata branch is pushed to this remote. +func (s *ManualCommitStrategy) recordPendingPushRemote(remote string) { + logCtx := logging.WithComponent(context.Background(), "checkpoint") + + worktreePath, err := GetWorktreePath() + if err != nil { + logging.Debug(logCtx, "recordPendingPushRemote: failed to get worktree path", + slog.String("error", err.Error())) + return + } + + sessions, err := s.findSessionsForWorktree(worktreePath) + if err != nil || len(sessions) == 0 { + return + } + + for _, state := range sessions { + if state.Phase != session.PhaseActiveCommitted || state.PendingCheckpointID == "" { + continue + } + + state.PendingPushRemote = remote + if err := s.saveSessionState(state); err != nil { + logging.Warn(logCtx, "recordPendingPushRemote: failed to save session state", + slog.String("session_id", state.SessionID), + slog.String("error", err.Error())) + continue + } + + logging.Info(logCtx, "pre-push: recorded pending push remote for deferred condensation", + slog.String("session_id", state.SessionID), + slog.String("remote", remote)) + } +} diff --git a/cmd/entire/cli/strategy/manual_commit_push_test.go b/cmd/entire/cli/strategy/manual_commit_push_test.go new file mode 100644 index 000000000..f9c40d9ec --- /dev/null +++ b/cmd/entire/cli/strategy/manual_commit_push_test.go @@ -0,0 +1,197 @@ +package strategy + +import ( + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/session" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRecordPendingPushRemote_ActiveCommittedSession verifies that +// recordPendingPushRemote sets PendingPushRemote on ACTIVE_COMMITTED sessions +// that have a PendingCheckpointID. +func TestRecordPendingPushRemote_ActiveCommittedSession(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-push-active-committed" + + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) + + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActiveCommitted + state.PendingCheckpointID = "a1b2c3d4e5f6" + require.NoError(t, s.saveSessionState(state)) + + s.recordPendingPushRemote("origin") + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + assert.Equal(t, "origin", state.PendingPushRemote, + "PendingPushRemote should be set for ACTIVE_COMMITTED session with PendingCheckpointID") +} + +// TestRecordPendingPushRemote_IdleSession_Skipped verifies that +// recordPendingPushRemote does not set PendingPushRemote on IDLE sessions. +func TestRecordPendingPushRemote_IdleSession_Skipped(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-push-idle-skipped" + + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) + + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseIdle + state.PendingCheckpointID = "a1b2c3d4e5f6" + require.NoError(t, s.saveSessionState(state)) + + s.recordPendingPushRemote("origin") + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + assert.Empty(t, state.PendingPushRemote, + "PendingPushRemote should not be set for IDLE session") +} + +// TestRecordPendingPushRemote_ActiveCommitted_NoPendingID_Skipped verifies that +// recordPendingPushRemote does not set PendingPushRemote on ACTIVE_COMMITTED +// sessions that have no PendingCheckpointID. +func TestRecordPendingPushRemote_ActiveCommitted_NoPendingID_Skipped(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-push-ac-no-pending-id" + + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) + + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActiveCommitted + state.PendingCheckpointID = "" + require.NoError(t, s.saveSessionState(state)) + + s.recordPendingPushRemote("origin") + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + assert.Empty(t, state.PendingPushRemote, + "PendingPushRemote should not be set when PendingCheckpointID is empty") +} + +// TestRecordPendingPushRemote_NoSessions_Noop verifies that +// recordPendingPushRemote is a no-op when there are no sessions. +func TestRecordPendingPushRemote_NoSessions_Noop(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + s := &ManualCommitStrategy{} + + // Should not panic or error with no sessions + s.recordPendingPushRemote("origin") +} + +// TestHandleTurnEnd_PushAfterCondensation verifies the full flow: +// PostCommit defers condensation (ACTIVE → ACTIVE_COMMITTED), +// recordPendingPushRemote records the remote, and HandleTurnEnd +// condenses and clears PendingPushRemote. +func TestHandleTurnEnd_PushAfterCondensation(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-turnend-push" + + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) + + // Simulate agent mid-turn + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(state)) + + // Agent commits → PostCommit transitions ACTIVE → ACTIVE_COMMITTED + commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c3") + err = s.PostCommit() + require.NoError(t, err) + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + require.Equal(t, session.PhaseActiveCommitted, state.Phase) + require.NotEmpty(t, state.PendingCheckpointID) + + // Agent pushes → recordPendingPushRemote records remote + s.recordPendingPushRemote("origin") + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + assert.Equal(t, "origin", state.PendingPushRemote) + + // Turn ends → HandleTurnEnd condenses and clears PendingPushRemote + result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{}) + remaining := session.ApplyCommonActions(state, result) + + err = s.HandleTurnEnd(state, remaining) + require.NoError(t, err) + + // PendingPushRemote should be cleared after turn-end + assert.Empty(t, state.PendingPushRemote, + "PendingPushRemote should be cleared after turn-end push") + + // Condensation should have succeeded + assert.Equal(t, 0, state.StepCount, + "StepCount should be reset after condensation") +} + +// TestInitializeSession_ClearsPendingPushRemote verifies that PendingPushRemote +// is cleared on new prompt start (handles Ctrl-C recovery case). +func TestInitializeSession_ClearsPendingPushRemote(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + s := &ManualCommitStrategy{} + sessionID := "test-init-clears-push-remote" + + // First call creates the session + err := s.InitializeSession(sessionID, "Claude Code", "", "first prompt") + require.NoError(t, err) + + // Simulate stale PendingPushRemote (e.g., from a Ctrl-C before turn-end) + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.PendingPushRemote = "origin" + state.Phase = session.PhaseIdle + now := time.Now() + state.LastInteractionTime = &now + require.NoError(t, s.saveSessionState(state)) + + // Second call should clear PendingPushRemote + err = s.InitializeSession(sessionID, "Claude Code", "", "second prompt") + require.NoError(t, err) + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + assert.Empty(t, state.PendingPushRemote, + "PendingPushRemote should be cleared on new prompt start") +} From 83ad2afb0ffc2d2bb742bb62155cdddacaa3b463 Mon Sep 17 00:00:00 2001 From: Daniel Weinmann Date: Thu, 12 Feb 2026 10:18:48 -0300 Subject: [PATCH 2/3] Fix PendingPushRemote not cleared on early returns in handleTurnEndCondense Extract flushPendingPush helper and use defer to guarantee PendingPushRemote is cleared on all exit paths, not just the happy path after condensation. Entire-Checkpoint: 919ada5b9e3e --- .../cli/strategy/manual_commit_hooks.go | 15 +----- cmd/entire/cli/strategy/manual_commit_push.go | 17 ++++++ .../cli/strategy/manual_commit_push_test.go | 52 +++++++++++++++++++ 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 5196da30e..7557ce821 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1483,6 +1483,8 @@ func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState, actions []sess // handleTurnEndCondense performs deferred condensation at turn end. func (s *ManualCommitStrategy) handleTurnEndCondense(logCtx context.Context, state *SessionState) { + defer s.flushPendingPush(logCtx, state) + repo, err := OpenRepository() if err != nil { logging.Warn(logCtx, "turn-end condense: failed to open repo", @@ -1536,19 +1538,6 @@ func (s *ManualCommitStrategy) handleTurnEndCondense(logCtx context.Context, sta s.condenseAndUpdateState(logCtx, repo, checkpointID, state, head, shadowBranchName, shadowBranchesToDelete) - // Push condensed checkpoint data if a push happened while condensation was deferred - if state.PendingPushRemote != "" { - remote := state.PendingPushRemote - state.PendingPushRemote = "" - logging.Info(logCtx, "turn-end: pushing condensed checkpoint data", - slog.String("remote", remote)) - if pushErr := pushSessionsBranchCommon(remote, paths.MetadataBranchName); pushErr != nil { - logging.Warn(logCtx, "turn-end: failed to push condensed checkpoint data", - slog.String("remote", remote), - slog.String("error", pushErr.Error())) - } - } - // Delete shadow branches after condensation — but only if no other active // sessions share the branch (same safety check PostCommit uses). for branchName := range shadowBranchesToDelete { diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index cc6ba4394..b6476d3d6 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -20,6 +20,23 @@ func (s *ManualCommitStrategy) PrePush(remote string) error { return pushSessionsBranchCommon(remote, paths.MetadataBranchName) } +// flushPendingPush pushes the metadata branch if a push was recorded while +// condensation was deferred, then clears PendingPushRemote. Intended to be +// called via defer so that all exit paths in handleTurnEndCondense clear the +// field. +func (s *ManualCommitStrategy) flushPendingPush(logCtx context.Context, state *SessionState) { + if state.PendingPushRemote == "" { + return + } + remote := state.PendingPushRemote + state.PendingPushRemote = "" + logging.Info(logCtx, "turn-end: pushing checkpoint data", slog.String("remote", remote)) + if pushErr := pushSessionsBranchCommon(remote, paths.MetadataBranchName); pushErr != nil { + logging.Warn(logCtx, "turn-end: failed to push checkpoint data", + slog.String("remote", remote), slog.String("error", pushErr.Error())) + } +} + // recordPendingPushRemote records the push remote on sessions that have deferred // condensation (ACTIVE_COMMITTED with PendingCheckpointID). When condensation // completes at turn-end, the metadata branch is pushed to this remote. diff --git a/cmd/entire/cli/strategy/manual_commit_push_test.go b/cmd/entire/cli/strategy/manual_commit_push_test.go index f9c40d9ec..10d051f49 100644 --- a/cmd/entire/cli/strategy/manual_commit_push_test.go +++ b/cmd/entire/cli/strategy/manual_commit_push_test.go @@ -164,6 +164,58 @@ func TestHandleTurnEnd_PushAfterCondensation(t *testing.T) { "StepCount should be reset after condensation") } +// TestHandleTurnEnd_PushClearedWhenNoNewContent verifies that PendingPushRemote +// is cleared even when handleTurnEndCondense returns early due to no new +// transcript content (the !hasNew path at the early return). +func TestHandleTurnEnd_PushClearedWhenNoNewContent(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "test-turnend-push-no-content" + + setupSessionWithCheckpoint(t, s, repo, dir, sessionID) + + // Simulate agent mid-turn + state, err := s.loadSessionState(sessionID) + require.NoError(t, err) + state.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(state)) + + // Agent commits → PostCommit transitions ACTIVE → ACTIVE_COMMITTED + commitWithCheckpointTrailer(t, repo, dir, "d4e5f6a1b2c3") + err = s.PostCommit() + require.NoError(t, err) + + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + require.Equal(t, session.PhaseActiveCommitted, state.Phase) + + // Record pending push remote + s.recordPendingPushRemote("origin") + state, err = s.loadSessionState(sessionID) + require.NoError(t, err) + require.Equal(t, "origin", state.PendingPushRemote) + + // Set CheckpointTranscriptStart = 2 so sessionHasNewContent returns false + // (transcript has exactly 2 lines from setupSessionWithCheckpoint) + state.CheckpointTranscriptStart = 2 + require.NoError(t, s.saveSessionState(state)) + + // Turn ends → HandleTurnEnd should still clear PendingPushRemote + result := session.Transition(state.Phase, session.EventTurnEnd, session.TransitionContext{}) + remaining := session.ApplyCommonActions(state, result) + + err = s.HandleTurnEnd(state, remaining) + require.NoError(t, err) + + assert.Empty(t, state.PendingPushRemote, + "PendingPushRemote should be cleared even when no new content to condense") +} + // TestInitializeSession_ClearsPendingPushRemote verifies that PendingPushRemote // is cleared on new prompt start (handles Ctrl-C recovery case). func TestInitializeSession_ClearsPendingPushRemote(t *testing.T) { From 1a885de82714d5282a16e2939410bca6ba091ffa Mon Sep 17 00:00:00 2001 From: Daniel Weinmann Date: Thu, 12 Feb 2026 10:32:39 -0300 Subject: [PATCH 3/3] Clarify doc comments on flushPendingPush and recordPendingPushRemote Entire-Checkpoint: 9624f3b6a506 --- cmd/entire/cli/strategy/manual_commit_push.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index b6476d3d6..b9dc12571 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -23,7 +23,7 @@ func (s *ManualCommitStrategy) PrePush(remote string) error { // flushPendingPush pushes the metadata branch if a push was recorded while // condensation was deferred, then clears PendingPushRemote. Intended to be // called via defer so that all exit paths in handleTurnEndCondense clear the -// field. +// field. The caller is responsible for persisting state after this returns. func (s *ManualCommitStrategy) flushPendingPush(logCtx context.Context, state *SessionState) { if state.PendingPushRemote == "" { return @@ -37,9 +37,9 @@ func (s *ManualCommitStrategy) flushPendingPush(logCtx context.Context, state *S } } -// recordPendingPushRemote records the push remote on sessions that have deferred -// condensation (ACTIVE_COMMITTED with PendingCheckpointID). When condensation -// completes at turn-end, the metadata branch is pushed to this remote. +// recordPendingPushRemote records the push remote on all sessions that have +// deferred condensation (ACTIVE_COMMITTED with PendingCheckpointID). Each +// session's flushPendingPush handles its own state independently at turn-end. func (s *ManualCommitStrategy) recordPendingPushRemote(remote string) { logCtx := logging.WithComponent(context.Background(), "checkpoint")