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..7557ce821 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). @@ -1480,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", diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 9694d238a..b9dc12571 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,60 @@ 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) } + +// 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. The caller is responsible for persisting state after this returns. +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 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") + + 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..10d051f49 --- /dev/null +++ b/cmd/entire/cli/strategy/manual_commit_push_test.go @@ -0,0 +1,249 @@ +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") +} + +// 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) { + 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") +}