Skip to content

Commit 079d8bf

Browse files
peyton-altclaude
andcommitted
fix(review): address PR #1844 review findings
Review findings from five reviewers (four specialized agents + Copilot/ Bugbot on the PR), converging on two must-fix items and a set of observability and comment-accuracy fixes: - CondenseAndMarkFullyCondensed now re-checks Phase == ENDED under the state lock (flagged by three reviewers + Bugbot): the detached child can lose a race with a same-ID resume, and condensing a revived session would wipe its in-flight attribution and leave a sticky FullyCondensed=true that makes PostCommit skip the session's next end. - handleLifecycleSessionEnd runs teardown on context.WithoutCancel (Copilot): the graceful half of hook cancellation is a SIGTERM, which cancels the root context — the ENDED mark must survive it. - entire-dev runs the cached binary without exec and falls back to the PATH binary on launch failure (126/127): a concurrent rebuild rewrites the output in place, and a failed exec previously aborted the shell without reaching any fallback, breaking the never-block contract. Also: per-cause fallback messages, build stderr captured to .entire/tmp/entire-dev-build.log instead of discarded, and go build -C so invocation from outside the repo resolves the right module (latent pre-existing bug). - SpawnDetached returns the start error and the SessionEnd hook logs the handoff and any spawn failure: the parent is the last process with working logging, and a silently failed spawn previously left zero evidence that a condense was ever requested. - Comment corrections: the exited-session sweep cannot retry an ENDED session (it is ACTIVE-only — doctor is the real second retry surface); endSessionNow's stale lockstep framing; the ~1.5s Claude Code budget now stated once with provenance and referenced elsewhere; the spawn seam's rationale; the cached-binary reuse claim corrected to what go actually does (rewrites in place, skips only the relink). - Tests: reactivated-session guard subtest (fails without the fix), rootErr sync-fallback test, seam records the worktree root, launcher launch-contract test (exit-status passthrough), __condense_session in the policy-warning table test, nil-guard in the polling test's timeout path. - CLAUDE.md hidden-commands list updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 01KY8GX54M99KSD53H232HEKB4
1 parent 540f83e commit 079d8bf

14 files changed

Lines changed: 306 additions & 72 deletions

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,9 @@ Deprecated top-level commands (functional, print a cobra deprecation message):
132132
deprecation as `checkpoint rewind`).
133133

134134
Hidden infrastructure commands: `hooks`, `trail`,
135-
`curl-bash-post-install`, `__send_analytics`, `mcp` (MCP stdio server for
136-
MCP-host agents).
135+
`curl-bash-post-install`, `__send_analytics`, `__refresh_trail_enablement`,
136+
`__condense_session` (detached session-end condense child), `mcp` (MCP stdio
137+
server for MCP-host agents).
137138

138139
The `hideAsAlias(cmd, canonical)` helper in `cmd/entire/cli/aliascmd.go`
139140
marks a command Hidden and sets cobra's `Deprecated` field so the hint

cmd/entire/cli/checkpoint_policy_warning_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ func TestShouldCheckCheckpointPolicyWarning(t *testing.T) {
5050
refreshTrailEnablement := &cobra.Command{Use: "__refresh_trail_enablement", Hidden: true}
5151
root.AddCommand(refreshTrailEnablement)
5252

53+
condenseSession := &cobra.Command{Use: "__condense_session", Hidden: true}
54+
root.AddCommand(condenseSession)
55+
5356
require.True(t, ShouldCheckCheckpointPolicyWarning(visible))
5457
require.True(t, ShouldCheckCheckpointPolicyWarning(hiddenAlias))
5558
require.False(t, ShouldCheckCheckpointPolicyWarning(gitHook))
5659
require.False(t, ShouldCheckCheckpointPolicyWarning(sendAnalytics))
5760
require.False(t, ShouldCheckCheckpointPolicyWarning(refreshTrailEnablement))
61+
require.False(t, ShouldCheckCheckpointPolicyWarning(condenseSession))
5862
}

cmd/entire/cli/execx/spawn_detached.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package execx
22

33
import (
44
"context"
5+
"fmt"
56
"io"
67
"os"
78
"os/exec"
@@ -13,19 +14,22 @@ import (
1314
// CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS on Windows, via detachFromTTY).
1415
// The child runs in dir (os.TempDir() when empty, so the child never holds the
1516
// parent's working directory), inherits the parent's environment, and has its
16-
// stdout/stderr discarded. Best-effort: every error is swallowed — callers
17-
// treat the spawn as advisory background work.
17+
// stdout/stderr discarded. Best-effort: the spawn is advisory background work
18+
// and the returned error exists only so callers can log that the child never
19+
// started (e.g. the running executable was deleted out from under us) — the
20+
// parent is the last process with working logging, so discarding it leaves no
21+
// evidence at all.
1822
//
1923
// In-process `go test` runs are a no-op: the current executable is the test
2024
// binary, and re-execing it would fork the whole suite. Tests exercise the
2125
// call sites through their spawn seams instead.
22-
func SpawnDetached(dir string, args ...string) {
26+
func SpawnDetached(dir string, args ...string) error {
2327
if testing.Testing() {
24-
return
28+
return nil
2529
}
2630
executable, err := os.Executable()
2731
if err != nil {
28-
return
32+
return fmt.Errorf("resolve current executable: %w", err)
2933
}
3034

3135
// context.Background(): the child must outlive the parent, so it is never
@@ -41,9 +45,10 @@ func SpawnDetached(dir string, args ...string) {
4145
cmd.Stderr = io.Discard
4246

4347
if err := cmd.Start(); err != nil {
44-
return
48+
return fmt.Errorf("start detached child (%v): %w", args, err)
4549
}
4650
// Release the process so it can run independently of the parent.
4751
//nolint:errcheck // best effort — the child continues regardless
4852
_ = cmd.Process.Release()
53+
return nil
4954
}

cmd/entire/cli/integration_test/hooks.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,9 @@ func (r *HookRunner) runHookInRepoDir(hookName string, inputJSON []byte) error {
229229
}
230230

231231
// runHookInRepoDirWithExtraEnv is like runHookInRepoDir but appends additional
232-
// env vars to the subprocess environment. Used by review-env adoption tests that
233-
// need ENTIRE_REVIEW_* vars present in the hook child process.
232+
// env vars to the subprocess environment, for tests that need extra or
233+
// overriding env vars in the hook child process (exec.Cmd keeps the last
234+
// value for a duplicated key, so appending overrides).
234235
func (r *HookRunner) runHookInRepoDirWithExtraEnv(hookName string, inputJSON []byte, extraEnv []string) error {
235236
// Run using the shared test binary
236237
// Command structure: entire hooks claude-code <hook-name>

cmd/entire/cli/integration_test/session_end_detached_test.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import (
1111

1212
// TestSessionEnd_DetachedCondense exercises the production SessionEnd flow:
1313
// the hook marks the session ENDED inline (fast enough for agents' short
14-
// SessionEnd budgets — Claude Code cancels the hook after ~1.5s) and hands
15-
// the eager condense to a detached __condense_session child that survives
16-
// the hook process being killed. The ENDED mark must be observable as soon
17-
// as the hook returns; the condense lands asynchronously.
14+
// SessionEnd budgets — see spawnDetachedSessionEndCondense) and hands the
15+
// eager condense to a detached __condense_session child that survives the
16+
// hook process being killed. The ENDED mark must be observable as soon as
17+
// the hook returns; the condense lands asynchronously.
1818
func TestSessionEnd_DetachedCondense(t *testing.T) {
1919
t.Parallel()
2020

@@ -70,6 +70,9 @@ func TestSessionEnd_DetachedCondense(t *testing.T) {
7070
break
7171
}
7272
if time.Now().After(deadline) {
73+
if state == nil {
74+
t.Fatal("detached condense did not complete within 15s and session state vanished")
75+
}
7376
t.Fatalf("detached condense did not mark session FullyCondensed within 15s (phase=%s)", state.Phase)
7477
}
7578
time.Sleep(100 * time.Millisecond)

cmd/entire/cli/lifecycle.go

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,43 +1020,62 @@ func handleLifecycleSessionEnd(ctx context.Context, ag agent.Agent, event *agent
10201020
// the transcript to extract file changes. Cleanup is handled by
10211021
// `entire clean` or when the session state is fully removed.
10221022

1023-
// Agents give SessionEnd hooks a short budget and never wait for them on
1024-
// exit (Claude Code cancels after ~1.5s), so only the fast ENDED mark runs
1025-
// inline; the eager condense — transcript reads plus git tree building —
1026-
// is handed to a detached child that survives the hook being killed.
1027-
// ENTIRE_SESSION_END_SYNC keeps the inline sequence (integration tests
1028-
// use it for determinism), as does a worktree root that can't be resolved
1029-
// (nowhere to run the child; dropping the condense would regress #591).
1030-
worktreeRoot, rootErr := paths.WorktreeRoot(ctx)
1023+
// Agents give SessionEnd hooks a short budget and do not wait for them on
1024+
// exit (see spawnDetachedSessionEndCondense for the observed figure), so
1025+
// only the fast ENDED mark runs inline; the eager condense — transcript
1026+
// reads plus git tree building — is handed to a detached child that
1027+
// survives the hook being killed. ENTIRE_SESSION_END_SYNC keeps the
1028+
// inline sequence (integration tests use it for determinism), as does a
1029+
// worktree root that can't be resolved (nowhere to run the child;
1030+
// dropping the condense would regress #591).
1031+
//
1032+
// The graceful half of that cancellation is a SIGTERM, which cancels the
1033+
// root context (see main.go) — the very signal this teardown must
1034+
// survive. Run the short, bounded finalization on a cancellation-immune
1035+
// context so the ENDED mark can't be aborted mid-write; a force-quit
1036+
// (second signal → SIGKILL) still terminates the process outright.
1037+
endCtx := context.WithoutCancel(ctx)
1038+
worktreeRoot, rootErr := paths.WorktreeRoot(endCtx)
10311039
if os.Getenv(envSessionEndSyncCondense) != "" || rootErr != nil {
1032-
if _, err := endSessionNow(ctx, event, event.SessionID, nil); err != nil {
1040+
if _, err := endSessionNow(endCtx, event, event.SessionID, nil); err != nil {
10331041
logging.Warn(logCtx, "failed to mark session ended",
10341042
slog.String("error", err.Error()))
10351043
}
10361044
return nil
10371045
}
10381046

1039-
ended, err := markSessionEnded(ctx, event, event.SessionID, nil)
1047+
ended, err := markSessionEnded(endCtx, event, event.SessionID, nil)
10401048
if err != nil {
10411049
logging.Warn(logCtx, "failed to mark session ended",
10421050
slog.String("error", err.Error()))
10431051
return nil
10441052
}
10451053
if ended {
1046-
sessionEndCondenseSpawn(worktreeRoot, event.SessionID)
1054+
logging.Info(logCtx, "handing session-end condense to detached child",
1055+
slog.String("session_id", event.SessionID))
1056+
if spawnErr := sessionEndCondenseSpawn(worktreeRoot, event.SessionID); spawnErr != nil {
1057+
// Fail-open, but never silent: the session is ENDED with its
1058+
// condense outstanding, and PostCommit (next commit) or `entire
1059+
// doctor` are the remaining retry surfaces.
1060+
logging.Warn(logCtx, "failed to spawn detached session-end condense",
1061+
slog.String("session_id", event.SessionID),
1062+
slog.String("error", spawnErr.Error()))
1063+
}
10471064
}
10481065

10491066
return nil
10501067
}
10511068

1052-
// endSessionNow runs the canonical "this session is over" sequence: it marks the
1053-
// session ended (firing the SessionStop transition → PhaseEnded + EndedAt) and
1054-
// eagerly condenses its pending work so PostCommit need not. This prevents
1069+
// endSessionNow runs the synchronous "this session is over" sequence: it marks
1070+
// the session ended (firing the SessionStop transition → PhaseEnded + EndedAt)
1071+
// and eagerly condenses its pending work so PostCommit need not. This prevents
10551072
// zombie ENDED sessions from accumulating and causing O(N) overhead on every
1056-
// future commit (GitHub issue #591). It is used by the exited-session sweep
1057-
// (finalizeExitedSessions) and by the SessionEnd hook's synchronous fallback
1058-
// (handleLifecycleSessionEnd, which normally detaches the condense instead),
1059-
// so the two stay in lockstep.
1073+
// future commit (GitHub issue #591). Callers: the exited-session sweep
1074+
// (finalizeExitedSessions — status/doctor have no hook budget, so condensing
1075+
// inline is fine there) and the SessionEnd hook's rarely-taken sync fallback.
1076+
// The normal hook path deliberately does NOT use this sequence: it calls
1077+
// markSessionEnded inline and hands the condense to a detached child — see
1078+
// handleLifecycleSessionEnd.
10601079
//
10611080
// The condense is fail-open (PostCommit retries on the next commit); an error
10621081
// marking the session ended is returned so callers can react, and skips the

cmd/entire/cli/session_end_condense.go

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"fmt"
45
"log/slog"
56

67
"github.com/entireio/cli/cmd/entire/cli/execx"
@@ -11,26 +12,36 @@ import (
1112
)
1213

1314
// envSessionEndSyncCondense forces the SessionEnd hook to run the eager
14-
// condense inline instead of handing it to a detached child. Integration
15-
// tests set it for determinism; it also serves as an escape hatch when the
16-
// detached child is undesirable (e.g. debugging).
15+
// condense inline instead of handing it to a detached child. Any non-empty
16+
// value enables it (including "0"/"false", matching the ACCESSIBLE
17+
// convention). Integration tests set it for determinism; it also serves as
18+
// an escape hatch when the detached child is undesirable (e.g. debugging).
1719
const envSessionEndSyncCondense = "ENTIRE_SESSION_END_SYNC"
1820

1921
// sessionEndCondenseSpawn is the process-spawn seam used by
20-
// handleLifecycleSessionEnd. Swapped in tests so they can assert the hook
21-
// requests a detached condense without forking a real subprocess (a real
22-
// `go test` binary doesn't understand `__condense_session` as an argument).
23-
// Production code always uses spawnDetachedSessionEndCondense.
22+
// handleLifecycleSessionEnd. SpawnDetached is already a no-op under `go test`
23+
// (see execx.SpawnDetached); this seam exists so unit tests can record that a
24+
// detached condense was requested, and with which arguments. Production code
25+
// always uses spawnDetachedSessionEndCondense.
2426
var sessionEndCondenseSpawn = spawnDetachedSessionEndCondense
2527

2628
// spawnDetachedSessionEndCondense starts `entire __condense_session <id>` as
2729
// a detached child so the eager condense survives the SessionEnd hook being
28-
// cancelled (agents give these hooks a short budget — Claude Code cancels
29-
// after ~1.5s — and never wait for them on exit). The child runs from the
30-
// worktree root because the strategy resolves the repo and session store
31-
// from its working directory.
32-
func spawnDetachedSessionEndCondense(worktreeRoot, sessionID string) {
33-
execx.SpawnDetached(worktreeRoot, "__condense_session", sessionID)
30+
// cancelled. Agents give SessionEnd hooks a short budget and do not wait for
31+
// them on exit — empirically ~1.5s for Claude Code (v2.1.x, observed 2026-07;
32+
// undocumented, so treat the figure as approximate). Other comments reference
33+
// this one rather than restating the number. The child runs from the worktree
34+
// root because the strategy resolves the repo and session store from its
35+
// working directory.
36+
//
37+
// The returned error means the child never started (e.g. the local-dev cache
38+
// binary was deleted between exec and spawn); callers stay fail-open but must
39+
// log it — the parent is the last process with working logging.
40+
func spawnDetachedSessionEndCondense(worktreeRoot, sessionID string) error {
41+
if err := execx.SpawnDetached(worktreeRoot, "__condense_session", sessionID); err != nil {
42+
return fmt.Errorf("spawn detached session-end condense: %w", err)
43+
}
44+
return nil
3445
}
3546

3647
// newCondenseSessionCmd creates the hidden command that runs the eager
@@ -49,7 +60,8 @@ func newCondenseSessionCmd() *cobra.Command {
4960
// .entire/logs/entire.log rather than vanishing. Guard on
5061
// WorktreeRoot first — matching __refresh_trail_enablement — so a
5162
// child whose worktree was removed between spawn and exec doesn't
52-
// create a stray .entire/logs/ in an arbitrary directory.
63+
// create a stray .entire/logs/ in an arbitrary directory
64+
// (logging.Init falls back to cwd when WorktreeRoot fails).
5365
if _, err := paths.WorktreeRoot(ctx); err == nil {
5466
logging.SetLogLevelGetter(GetLogLevel)
5567
if err := logging.Init(ctx, ""); err == nil {
@@ -59,8 +71,11 @@ func newCondenseSessionCmd() *cobra.Command {
5971
sessionID := args[0]
6072
if err := GetStrategy(ctx).CondenseAndMarkFullyCondensed(ctx, sessionID); err != nil {
6173
// Fail-open, like the inline condense it replaces: PostCommit
62-
// retries on the next commit and the exited-session sweep
63-
// retries from status/doctor.
74+
// retries on the next commit, and `entire doctor` detects and
75+
// offers to condense stuck ended sessions. (The exited-session
76+
// sweep does NOT cover this — it only finalizes ACTIVE
77+
// sessions whose owner died, and this session is already
78+
// ENDED.)
6479
logging.Warn(logging.WithComponent(ctx, "lifecycle"),
6580
"detached session-end condense failed",
6681
slog.String("session_id", sessionID),

cmd/entire/cli/session_end_condense_test.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"context"
5+
"path/filepath"
56
"testing"
67
"time"
78

@@ -13,15 +14,22 @@ import (
1314
"github.com/stretchr/testify/require"
1415
)
1516

17+
// spawnCall records one request to the detached-condense spawn seam.
18+
type spawnCall struct {
19+
worktreeRoot string
20+
sessionID string
21+
}
22+
1623
// swapSessionEndCondenseSpawn replaces the detached-condense spawn seam with a
1724
// recorder and restores it on cleanup. Tests using it must not run in parallel
1825
// (package-level seam).
19-
func swapSessionEndCondenseSpawn(t *testing.T) *[]string {
26+
func swapSessionEndCondenseSpawn(t *testing.T) *[]spawnCall {
2027
t.Helper()
21-
var spawned []string
28+
var spawned []spawnCall
2229
orig := sessionEndCondenseSpawn
23-
sessionEndCondenseSpawn = func(_, sessionID string) {
24-
spawned = append(spawned, sessionID)
30+
sessionEndCondenseSpawn = func(worktreeRoot, sessionID string) error {
31+
spawned = append(spawned, spawnCall{worktreeRoot: worktreeRoot, sessionID: sessionID})
32+
return nil
2533
}
2634
t.Cleanup(func() { sessionEndCondenseSpawn = orig })
2735
return &spawned
@@ -60,8 +68,32 @@ func TestHandleLifecycleSessionEnd_SpawnsDetachedCondense(t *testing.T) {
6068
"session must be marked ENDED synchronously")
6169
assert.False(t, loaded.FullyCondensed,
6270
"condense must not run inline on the hook path")
63-
assert.Equal(t, []string{sessionID}, *spawned,
71+
require.Len(t, *spawned, 1,
6472
"exactly one detached condense must be requested for the ended session")
73+
assert.Equal(t, sessionID, (*spawned)[0].sessionID)
74+
// The child resolves the repo and session store from its working
75+
// directory, so spawning anywhere but the worktree root would make the
76+
// condense a silent no-op in the wrong place.
77+
wantRoot, err := filepath.EvalSymlinks(dir)
78+
require.NoError(t, err)
79+
gotRoot, err := filepath.EvalSymlinks((*spawned)[0].worktreeRoot)
80+
require.NoError(t, err)
81+
assert.Equal(t, wantRoot, gotRoot, "child must be spawned from the worktree root")
82+
}
83+
84+
// TestHandleLifecycleSessionEnd_NoRepoFallsBackToSync verifies that when the
85+
// worktree root can't be resolved there is nowhere to run the child, so the
86+
// handler takes the synchronous endSessionNow path instead of spawning —
87+
// dropping the condense silently would regress #591.
88+
func TestHandleLifecycleSessionEnd_NoRepoFallsBackToSync(t *testing.T) {
89+
t.Chdir(t.TempDir()) // plain directory, not a git repo
90+
91+
spawned := swapSessionEndCondenseSpawn(t)
92+
93+
event := &agent.Event{Type: agent.SessionEnd, SessionID: "some-session"}
94+
require.NoError(t, handleLifecycleSessionEnd(context.Background(), newMockAgent(), event))
95+
96+
assert.Empty(t, *spawned, "no detached condense without a resolvable worktree root")
6597
}
6698

6799
// TestHandleLifecycleSessionEnd_SyncEnvCondensesInline verifies the

0 commit comments

Comments
 (0)