status, doctor: finalize sessions whose agent has exited · Entire

status, doctor: finalize sessions whose agent has exited

b7e2f3a·

Soph·3w ago·7 files·+278 added/-34 removed

Extract endSessionNow — the markSessionEnded + eager-condense sequence
the SessionStop hook already runs — and share it with a new
finalizeExitedSessions sweep, so the hook and the sweep stay in lockstep.
markSessionEnded gains an optional guard so the sweep re-checks
OwnerExited on the freshly-loaded state under the session-state lock,
closing a TOCTOU race where a turn could revive a session between the
list snapshot and the finalize.

entire status and entire doctor run the sweep up front, finalizing any
ACTIVE session whose owner process is gone (PhaseEnded + condense)
instead of leaving it "active" until the 1h StuckActiveThreshold. Both
surfaces also label such sessions "exited" (human output, status --json,
and doctor's stuck-session reason) as a fallback when finalization
can't run.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

1fcfee56345bView transcript

Changes

7

119 unmodified lines

120
121
122
123
124
125
126
127
128
129
130
131
132
133
84 unmodified lines

218
219
220
213
214
215
216
221
218
222
223
224
225
226
227
228
229
230
231
232
233
234
235
220
236
237
238
239

119 unmodified lines

}
    defer repo.Close()

// Finalize any ACTIVE session whose agent process has exited (no SessionStop  
    // hook fired). A gone process is unambiguous, so these are condensed on the  
    // spot rather than left for the interactive prompt below; the sweep marks  
    // them ended in place so classifySession won't re-flag them.  
    if n := finalizeExitedSessions(ctx, states); n > 0 {  
        fmt.Fprintf(cmd.OutOrStdout(), "Finalized %d exited session(s) (agent process gone).\n\n", n)  
    }

// Identify stuck sessions
    now := time.Now()
    var stuck []stuckSession
``

Mcmd/entire/cli/doctor.go+22/-6

``
932 unmodified lines

933
934
935
936
936
937
938
939
940
941
939
940
944
945
946
947
948
949
950
951
952
953
954
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
209 unmodified lines

1183
1184
1185
1173
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
3 unmodified lines

1204
1205
1206
1207
1208
1209
1186
1187
1210
1211
1212
1213
1190
1214
1215
1192
1216
1217
1218
1219

932 unmodified lines

// the transcript to extract file changes. Cleanup is handled by  
    // `entire clean` or when the session state is fully removed.

if err := markSessionEnded(ctx, event, event.SessionID); err != nil {  
        if _, err := endSessionNow(ctx, event, event.SessionID, nil); err != nil {  
            logging.Warn(logCtx, "failed to mark session ended",  
                slog.String("error", err.Error()))
            // Don't attempt eager condense if we couldn't even mark the session ended —  
            // the session state may be in an inconsistent state.
            return nil
        }

// Eagerly condense session data so PostCommit doesn't have to process it.
        // This prevents zombie ENDED sessions from accumulating and causing O(N)  
        // overhead on every future commit (GitHub issue #591).
        // Fail-open: if this fails, PostCommit will still process it on the next commit.
        strat := GetStrategy(ctx)
        if err := strat.CondenseAndMarkFullyCondensed(ctx, event.SessionID); err != nil {  
            logging.Warn(logCtx, "eager condense on session stop failed",  
                slog.String("session_id", event.SessionID),  
                slog.String("error", err.Error()))
        }

return nil
    }

// endSessionNow runs the canonical "this session is over" sequence: it marks the  
// session ended (firing the SessionStop transition → PhaseEnded + EndedAt) and  
// eagerly condenses its pending work so PostCommit need not. This prevents  
// zombie ENDED sessions from accumulating and causing O(N) overhead on every  
// future commit (GitHub issue #591). It is shared by the SessionStop hook  
// (handleLifecycleSessionEnd) and the exited-session sweep  
// (finalizeExitedSessions), so the two stay in lockstep.
// ...

Mcmd/entire/cli/lifecycle.go+44/-20

29 unmodified lines

30
31
32
33
33
34
35
36
23 unmodified lines

60
61
62
63
63
64
65
66
18 unmodified lines

85
86
87
88
88
89
90
91
18 unmodified lines

110
111
112
113
113
114
115
116
8 unmodified lines

125
126
127
128
128
129
130
131

29 unmodified lines

require.NoError(t, err)

// Call markSessionEnded
    err = markSessionEnded(context.Background(), nil, "test-session-end-1")
    _, err = markSessionEnded(context.Background(), nil, "test-session-end-1", nil)
    require.NoError(t, err)

// Verify phase is ENDED
    23 unmodified lines

err := strategy.SaveSessionState(context.Background(), state)
    require.NoError(t, err)

err = markSessionEnded(context.Background(), nil, "test-session-end-idle")
    _, err = markSessionEnded(context.Background(), nil, "test-session-end-idle", nil)
    require.NoError(t, err)

loaded, err := strategy.LoadSessionState(context.Background(), "test-session-end-idle")

18 unmodified lines

err := strategy.SaveSessionState(context.Background(), state)
    require.NoError(t, err)

err = markSessionEnded(context.Background(), nil, "test-session-end-noop")
    _, err = markSessionEnded(context.Background(), nil, "test-session-end-noop", nil)
    require.NoError(t, err)

loaded, err := strategy.LoadSessionState(context.Background(), "test-session-end-noop")

18 unmodified lines

err := strategy.SaveSessionState(context.Background(), state)
    require.NoError(t, err)

err = markSessionEnded(context.Background(), nil, "test-session-end-compat")
    _, err = markSessionEnded(context.Background(), nil, "test-session-end-compat", nil)
    require.NoError(t, err)

loaded, err := strategy.LoadSessionState(context.Background(), "test-session-end-compat")

8 unmodified lines

dir := setupGitRepoForPhaseTest(t)

t.Chdir(dir)

err = markSessionEnded(context.Background(), nil, "nonexistent-session")
    _, err := markSessionEnded(context.Background(), nil, "nonexistent-session", nil)
    assert.NoError(t, err, "should be a no-op when no state exists")
}

Acmd/entire/cli/session_finalize.go+58/-5


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127

//go:build linux || darwin

package cli

import (
    "context"
    "os"
    "testing"
    "time"

"github.com/entireio/cli/cmd/entire/cli/proclive"
    "github.com/entireio/cli/cmd/entire/cli/session"
)

// TestFinalizeExitedSessions finalizes an ACTIVE session whose owner process is
// gone, and leaves an ACTIVE session without a recorded owner untouched.
//
// Not parallel: setupAttachTestRepo uses t.Chdir.
func TestFinalizeExitedSessions(t *testing.T) {
    setupAttachTestRepo(t)
    ctx := context.Background()

store, err := session.NewStateStore(ctx)
    if err != nil {
        t.Fatal(err)
    }

// Owner with a mismatched start fingerprint reads as a reused (dead) PID, a
    // deterministic "agent exited" signal on linux/darwin.
    exited := &session.State{
        SessionID: "exited-session",
        Phase:     session.PhaseActive,
        StartedAt: time.Now(),
        Owner:     &proclive.Identity{PID: os.Getpid(), Start: "bogus-start-fingerprint"},
    }
    // No owner recorded: must be left alone (liveness unknown → timeout fallback).
    noOwner := &session.State{
        SessionID: "no-owner-session",
        Phase:     session.PhaseActive,
        StartedAt: time.Now(),
    }
    for _, s := range []*session.State{exited, noOwner} {
        if err := store.Save(ctx, s); err != nil {
            t.Fatalf("save %s: %v", s.SessionID, err)
        }
    }

states, err := store.List(ctx)
    if err != nil {
        t.Fatal(err)
    }

if n := finalizeExitedSessions(ctx, states); n != 1 {
        t.Fatalf("finalizeExitedSessions = %d, want 1", n)
    }

// The exited session is now ended on disk.
    got, err := store.Load(ctx, "exited-session")
    if err != nil {
        t.Fatal(err)
    }
    if got.EndedAt == nil {
                        t.Error("exited session EndedAt = nil, want set")
    }
    if got.Phase != session.PhaseEnded {
                        t.Errorf("exited session Phase = %q, want %q", got.Phase, session.PhaseEnded)
    }

// The owner-less session is untouched.
    got, err = store.Load(ctx, "no-owner-session")
    if err != nil {
        t.Fatal(err)
    }
    if got.EndedAt != nil {
                        t.Error("no-owner session EndedAt set, want nil (left active)")
    }
}

// TestFinalizeExitedSessions_RevalidatesUnderLock guards against the
// time-of-check/time-of-use race: the sweep must re-check OwnerExited on the
// freshly-loaded state, not act on a stale list snapshot. Here the on-disk
// state has a LIVE owner while the snapshot passed to the sweep carries a dead
// one (as if a turn revived the session after the list was taken).
//
// Not parallel: setupAttachTestRepo uses t.Chdir.
func TestFinalizeExitedSessions_RevalidatesUnderLock(t *testing.T) {
    setupAttachTestRepo(t)
    ctx := context.Background()

liveOwner, ok := proclive.ResolveOwner()
    if !ok {
        t.Skip("no stable process owner resolvable in this environment")
    }

store, err := session.NewStateStore(ctx)
    if err != nil {
        t.Fatal(err)
    }
    if err := store.Save(ctx, &session.State{
        SessionID: "revived",
        Phase:     session.PhaseActive,
        StartedAt: time.Now(),
        Owner:     &liveOwner, // on disk: a live owner
    }); err != nil {
        t.Fatal(err)
    }

// Stale snapshot the sweep sees: same session, but with a dead owner.
    stale := &session.State{
        SessionID: "revived",
        Phase:     session.PhaseActive,
        StartedAt: time.Now(),
        Owner:     &proclive.Identity{PID: os.Getpid(), Start: "bogus-start-fingerprint"},
    }

if n := finalizeExitedSessions(ctx, []*session.State{stale}); n != 0 {
        t.Fatalf("finalizeExitedSessions = %d, want 0 (revalidation should skip the revived session)", n)
    }

got, err := store.Load(ctx, "revived")
    if err != nil {
        t.Fatal(err)
    }
    if got.EndedAt != nil {
                        t.Error("revived session was ended despite a live owner on disk")
    }
}

Acmd/entire/cli/session_finalize_test.go+127

869 unmodified lines

870
871
872
873
873
874
875
876

869 unmodified lines

lastCheckpointID := state.LastCheckpointID
    stepCount := state.StepCount

if err := markSessionEnded(ctx, nil, sessionID); err != nil {
    if _, err := markSessionEnded(ctx, nil, sessionID, nil); err != nil {
        return fmt.Errorf("failed to stop session %s: %w", sessionID, err)
    }

Mcmd/entire/cli/sessions.go+1/-1

270 unmodified lines

271
272
273
274
275
276
277
278
279
280
281
282
283
284
102 unmodified lines

387
388
389
382
390
391
392
393
394
395
396
397
398
399
400
386
401
402
403
404
282 unmodified lines

687
688
689
690
691
692
693
694
695
696

270 unmodified lines

return
    }

// Finalize any ACTIVE session whose agent process has exited without a
    // SessionStop hook firing, so it doesn't linger as "active" until the
    // inactivity timeout. The sweep marks them ended in place, so the filter
    // below drops them.
    if n := finalizeExitedSessions(ctx, states); n > 0 {
        fmt.Fprintln(w, sty.render(sty.dim, fmt.Sprintf("Finalized %d exited session(s) (agent process gone).", n)))
    }

// Filter to active sessions only
    var active []*session.State
    for _, s := range states {
102 unmodified lines

}

statsLine := strings.Join(stats, sty.render(sty.dim, " · "))
        if st.IsStuckActive() {
            switch {
            case st.OwnerExited():
                // Agent process is gone but the session couldn't be finalized
                // above (e.g. condense/transition error); flag it explicitly.
                fmt.Fprintf(w, "%s %s %s\n", sty.render(sty.dim, statsLine),
                    sty.render(sty.dim, "·"),
                    sty.render(sty.yellow, "exited")+" (run 'entire doctor')")
            case st.IsStuckActive():
                fmt.Fprintf(w, "%s %s %s\n", sty.render(sty.dim, statsLine),
                    sty.render(sty.dim, "·"),
                    sty.render(sty.yellow, "stale")+" (run 'entire doctor')")
            }
        } else {
                fmt.Fprintln(w, sty.render(sty.dim, statsLine))
        }
        if warning := divergenceWarnings[st.SessionID]; warning != "" {
282 unmodified lines

if s.EndedAt != nil {
        return "ended"
    }
    if s.OwnerExited() {
        // ACTIVE on disk, but the owning agent process is gone.
        return "exited"
    }
    if s.Phase != "" {
        return string(s.Phase)
    }
}

Mcmd/entire/cli/status.go+21/-2