From 15967ae644a355391d6237b912749260be95ea0f Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Mon, 19 Oct 2026 11:36:23 -0700 Subject: [PATCH] Keep other worktrees' sessions off an identified committer's commit An agent started in the main checkout that moved into a linked worktree (Claude Code's EnterWorktree) commits there with no session homed in it. Worktree matching then fell back to every session of the main checkout, and the shortcuts that skip content checks condensed each one that was mid-turn into the commit. When process ancestry identifies the committer and no session is homed in the committing worktree, the fallback's sessions are dropped; a session from elsewhere joins only through its record of this worktree, held to those edits. Sessions homed here still union with the committer, and a commit with no identified committer keeps the fallback. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M5AQ6MYHK7RMSDMZAFZ79Q4R --- .../cli/strategy/manual_commit_session.go | 18 +-- cmd/entire/cli/strategy/session_identity.go | 20 ++- .../cli/strategy/session_identity_test.go | 53 ++++++++ cmd/entire/cli/strategy/worktree_home_test.go | 2 +- cmd/entire/cli/strategy/worktree_work_test.go | 119 ++++++++++++++++++ docs/development/checkpoint-implementation.md | 2 +- 6 files changed, 201 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index bcc6372522..c791cf00d3 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -327,7 +327,7 @@ func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, work if err != nil { return nil, err } - matches, _ := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath) + matches, _, _ := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath) return matches, nil } @@ -335,11 +335,13 @@ func (s *ManualCommitStrategy) findSessionsForWorktree(ctx context.Context, work // already-loaded state list. The second result reports a multi-worktree // ambiguity decline — candidates existed but spanned several worktrees, so // nothing was linked; findSessionsForCommitLinking surfaces it to the user -// only when identity matching cannot rescue the commit either. -func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Context, allStates []*SessionState, worktreePath string) ([]*SessionState, bool) { +// only when identity matching cannot rescue the commit either. The third +// reports that no session is homed in worktreePath, so the matches (if any) +// are sessions of other worktrees from the fallback. +func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Context, allStates []*SessionState, worktreePath string) (matches []*SessionState, ambiguous, fromFallback bool) { exact, notHome := matchWorktreeHomes(ctx, allStates, worktreePath) if len(exact) > 0 { - return exact, false + return exact, false, false } worktreeCommonDir, err := gitCommonDirForWorktree(ctx, worktreePath) @@ -347,7 +349,7 @@ func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Con logging.Debug(logging.WithComponent(ctx, "checkpoint"), "session matching: cannot resolve common dir for fallback matching", slog.String("error", err.Error())) - return nil, false + return nil, false, true } var parentWorktreeMatches []*SessionState @@ -377,10 +379,12 @@ func (s *ManualCommitStrategy) findSessionsForWorktreeFromStates(ctx context.Con commonDirMatches = append(commonDirMatches, state) } + candidates := commonDirMatches if len(parentWorktreeMatches) > 0 { - return resolveWorktreeCandidates(ctx, worktreePath, parentWorktreeMatches) + candidates = parentWorktreeMatches } - return resolveWorktreeCandidates(ctx, worktreePath, commonDirMatches) + matches, ambiguous = resolveWorktreeCandidates(ctx, worktreePath, candidates) + return matches, ambiguous, true } // recentSessionWindow bounds the liveness filter below: a session that diff --git a/cmd/entire/cli/strategy/session_identity.go b/cmd/entire/cli/strategy/session_identity.go index 629955345f..30e224b914 100644 --- a/cmd/entire/cli/strategy/session_identity.go +++ b/cmd/entire/cli/strategy/session_identity.go @@ -15,9 +15,13 @@ import ( // content, so every session with pending content here belongs in it — // concurrent sessions interleave by design) and the identity-matched session // when the committing process's ancestry names one that worktree matching -// missed. There is no precedence between the two — an identity hit must -// never suppress worktree matches, or concurrent same-worktree sessions -// would drop out of the commit. The identity union is what makes an +// missed. An identity hit never suppresses sessions homed in this worktree, +// or concurrent same-worktree sessions would drop out of the commit. It does +// replace the fallback's matches when no session is homed here: those belong +// to other worktrees, and a session there that edited this worktree through a +// shell command mid-turn (no record of it until turn end) is dropped too. +// Without an identified committer (Windows, no recorded owner) the fallback's +// matches still link. The identity union is what makes an // agent-made commit immune to worktree bookkeeping drift: an agent // committing in a sibling worktree still links to its own session // (guest-linked), where path matching alone found nothing or declined as @@ -35,8 +39,16 @@ func (s *ManualCommitStrategy) findSessionsForCommitLinking(ctx context.Context, // rescue this; report it to the caller (hooks log and skip). return nil, err } - sessions, ambiguous := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath) + sessions, ambiguous, fromFallback := s.findSessionsForWorktreeFromStates(ctx, allStates, worktreePath) guest := s.findSessionByCommitAncestry(ctx, allStates) + if guest != nil && fromFallback { + // No session is homed here and ancestry names the committer: the + // fallback's sessions belong to other worktrees, and one there that + // is mid-turn would otherwise be condensed into this commit by the + // shortcuts that skip content checks. A session that recorded edits + // here still joins below, held to them. + sessions = nil + } if guest != nil && !linkingSetContains(sessions, guest.SessionID) { sessions = append(sessions, guest) } diff --git a/cmd/entire/cli/strategy/session_identity_test.go b/cmd/entire/cli/strategy/session_identity_test.go index ae01ac4d82..215c332dbd 100644 --- a/cmd/entire/cli/strategy/session_identity_test.go +++ b/cmd/entire/cli/strategy/session_identity_test.go @@ -531,3 +531,56 @@ func TestIsNearerOwner_Contract(t *testing.T) { }) } } + +// ENT-2463 through the commit hooks: the committer, started in the main +// checkout, commits from a sibling worktree where no session is homed, while +// another main-checkout session is mid-turn with pending work. Only the +// committer is stamped and condensed; the other session keeps its pending +// work for its own commit. +// +// Not parallel: uses t.Chdir(). +func TestCommitHooks_IdentifiedCommitterLeavesMainCheckoutSessionsPending(t *testing.T) { + ctx := context.Background() + homeDir, err := filepath.EvalSymlinks(setupGitRepo(t)) + require.NoError(t, err, "the recorded home must match git's worktree list") + t.Chdir(homeDir) + homeRepo, err := gitrepo.OpenPath(homeDir) + require.NoError(t, err) + t.Cleanup(func() { _ = homeRepo.Close() }) + + s := NewManualCommitStrategy() + setupSessionWithCheckpoint(t, s, homeRepo, homeDir, "sess-unrelated") + unrelated, err := s.loadSessionState(ctx, "sess-unrelated") + require.NoError(t, err) + unrelated.WorktreePath = homeDir + unrelated.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(ctx, unrelated)) + stepsBefore := unrelated.StepCount + require.Positive(t, stepsBefore, "sanity: the unrelated session has pending work") + + setupSessionWithCheckpointAndFile(t, s, homeDir, "sess-committer", "notes.txt") + committer, err := s.loadSessionState(ctx, "sess-committer") + require.NoError(t, err) + committer.Owner = selfAncestorOwner(t) + committer.WorktreePath = homeDir + committer.Phase = session.PhaseActive + require.NoError(t, s.saveSessionState(ctx, committer)) + + guestDir := addSiblingWorktree(t, homeDir, "committer-wt") + t.Chdir(guestDir) + testutil.WriteFile(t, guestDir, "guest.txt", "committer's shell edit") + testutil.GitAdd(t, guestDir, "guest.txt") + msg := prepareMessage(t, "committer's change\n") + cps := checkpointIDs(msg) + require.Len(t, cps, 1, "the committer's commit is stamped: %q", msg) + testutil.GitCommit(t, guestDir, msg) + require.NoError(t, NewManualCommitStrategy().PostCommit(ctx)) + + unrelated, err = s.loadSessionState(ctx, "sess-unrelated") + require.NoError(t, err) + assert.Equal(t, stepsBefore, unrelated.StepCount, "the main checkout's mid-turn session keeps its pending work") + assert.NotEqual(t, cps[0], unrelated.LastCheckpointID.String(), "the main checkout's session is not condensed into the commit") + committer, err = s.loadSessionState(ctx, "sess-committer") + require.NoError(t, err) + assert.Equal(t, cps[0], committer.LastCheckpointID.String(), "the stamped checkpoint is the committer's") +} diff --git a/cmd/entire/cli/strategy/worktree_home_test.go b/cmd/entire/cli/strategy/worktree_home_test.go index 65627d1fd9..3e3180645d 100644 --- a/cmd/entire/cli/strategy/worktree_home_test.go +++ b/cmd/entire/cli/strategy/worktree_home_test.go @@ -140,7 +140,7 @@ func TestFindSessionsForWorktree_FindsAMovedHome(t *testing.T) { chdirWorktree(t, moved) states := mustListStates(t.Context(), t, s) - matches, ambiguous := s.findSessionsForWorktreeFromStates(t.Context(), states, moved) + matches, ambiguous, _ := s.findSessionsForWorktreeFromStates(t.Context(), states, moved) require.False(t, ambiguous) require.Len(t, matches, 1, "the moved home's commit found no session") assert.Equal(t, moved, matches[0].WorktreePath, "the moved home isn't seen as the session's home") diff --git a/cmd/entire/cli/strategy/worktree_work_test.go b/cmd/entire/cli/strategy/worktree_work_test.go index 6ad3f7e02a..bdddebd0ba 100644 --- a/cmd/entire/cli/strategy/worktree_work_test.go +++ b/cmd/entire/cli/strategy/worktree_work_test.go @@ -2,8 +2,10 @@ package strategy import ( "context" + "maps" "os" "path/filepath" + "slices" "testing" "time" @@ -316,3 +318,120 @@ func TestStoredSessionRoot_InWorktreeViewReadsTheHomesCopy(t *testing.T) { leave() assert.Empty(t, state.ViewHomePath) } + +// linkingIDs returns the IDs findSessionsForCommitLinking links to a commit +// made in root. +func linkingIDs(t *testing.T, root string) []string { + t.Helper() + sessions := linkingSessions(t, root) + ids := make([]string, 0, len(sessions)) + for id := range sessions { + ids = append(ids, id) + } + return ids +} + +// linkingSessions is linkingIDs keyed to the linked states. +func linkingSessions(t *testing.T, root string) map[string]*SessionState { + t.Helper() + t.Chdir(root) + clearSessionMatchCaches() + sessions, err := (&ManualCommitStrategy{}).findSessionsForCommitLinking(context.Background(), root) + require.NoError(t, err) + byID := make(map[string]*SessionState, len(sessions)) + for _, st := range sessions { + byID[st.SessionID] = st + } + return byID +} + +// ENT-2463: an agent started in the main checkout moved into a linked +// worktree (Claude Code's EnterWorktree) and committed there. No session is +// homed in that worktree, so the fallback matched every session of the main +// checkout, and each one mid-turn was condensed into the commit. Once process +// ancestry identifies the committer, a session from another worktree joins +// only through its record of this worktree. +func TestCommitLinking_IdentifiedCommitterKeepsOtherWorktreesSessionsOut(t *testing.T) { + for _, layout := range []struct{ name, linked string }{ + {"sibling worktree", ""}, + {"worktree nested under the main checkout", ".worktrees/nested"}, + } { + t.Run(layout.name, func(t *testing.T) { + ctx := context.Background() + s := &ManualCommitStrategy{} + var mainDir, linkedDir string + if layout.linked == "" { + mainDir, linkedDir = twoWorktrees(t) + } else { + testutil.IsolateGitConfigEnv(t) + mainDir = setupSessionMatchRepo(t) + linkedDir = filepath.Join(mainDir, layout.linked) + createSessionMatchWorktree(t, mainDir, linkedDir, "nested") + t.Cleanup(func() { removeSessionMatchWorktree(mainDir, linkedDir) }) + } + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "committer", WorktreePath: mainDir, Owner: selfAncestorOwner(t)}) + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "unrelated", WorktreePath: mainDir}) + testutil.WriteFile(t, linkedDir, "a.go", "package a\n") + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{ + SessionID: "edited-here", + WorktreePath: mainDir, + StepCount: 1, + OtherWorktrees: map[string]*session.WorktreeWork{ + linkedKey(t, linkedDir): {Path: linkedDir, FilesTouched: []string{"a.go"}, TouchedFileHashes: hashTouchedFiles(ctx, linkedDir, []string{"a.go"}), Incarnation: mark(t, linkedDir)}, + }, + }) + + linked := linkingSessions(t, linkedDir) + assert.ElementsMatch(t, []string{"committer", "edited-here"}, slices.Collect(maps.Keys(linked)), + "an identified committer keeps the main checkout's other sessions off its commit unless they recorded edits here") + if st := linked["edited-here"]; assert.NotNil(t, st) { + assert.True(t, st.LinkedByWorktreeEntry, "a session that joins through its record of this worktree is held to it") + } + }) + } +} + +// Without an identified committer (no recorded agent process, or one that is +// not an ancestor of the commit) the fallback still links the main checkout's +// sessions: nothing else names the commit's session. +func TestCommitLinking_FallbackWithoutIdentityKeepsMainCheckoutSessions(t *testing.T) { + mainDir, linkedDir := twoWorktrees(t) + ctx := context.Background() + s := &ManualCommitStrategy{} + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "first", WorktreePath: mainDir}) + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "second", WorktreePath: mainDir}) + + assert.ElementsMatch(t, []string{"first", "second"}, linkingIDs(t, linkedDir)) +} + +// A session homed in the committing worktree stays linked alongside an +// identified committer from elsewhere: the identity match adds to the +// worktree's own sessions, never replaces them. +func TestCommitLinking_IdentifiedCommitterKeepsTheWorktreesOwnSessions(t *testing.T) { + mainDir, linkedDir := twoWorktrees(t) + ctx := context.Background() + s := &ManualCommitStrategy{} + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "committer", WorktreePath: mainDir, Owner: selfAncestorOwner(t)}) + saveSessionMatchState(ctx, t, s, linkedDir, &SessionState{SessionID: "homed-here", WorktreePath: linkedDir}) + + assert.ElementsMatch(t, []string{"committer", "homed-here"}, linkingIDs(t, linkedDir)) +} + +// The committer is homed in a third worktree, so the fallback sees sessions of +// two worktrees and keeps the recently interacting one: the main checkout's, +// while the committer sat in a long tool call. Identity names the committer, +// and the main checkout's session stays off its commit. +func TestCommitLinking_IdentifiedCommitterFromThirdWorktree(t *testing.T) { + mainDir, linkedDir := twoWorktrees(t) + thirdDir := filepath.Join(resolvedTempDir(t), "third") + createSessionMatchWorktree(t, mainDir, thirdDir, "third") + t.Cleanup(func() { removeSessionMatchWorktree(mainDir, thirdDir) }) + ctx := context.Background() + s := &ManualCommitStrategy{} + now := time.Now() + earlier := now.Add(-2 * recentSessionWindow) + saveSessionMatchState(ctx, t, s, thirdDir, &SessionState{SessionID: "committer", WorktreePath: thirdDir, Owner: selfAncestorOwner(t), LastInteractionTime: &earlier}) + saveSessionMatchState(ctx, t, s, mainDir, &SessionState{SessionID: "main-session", WorktreePath: mainDir, LastInteractionTime: &now}) + + assert.ElementsMatch(t, []string{"committer"}, linkingIDs(t, linkedDir)) +} diff --git a/docs/development/checkpoint-implementation.md b/docs/development/checkpoint-implementation.md index 2c4ac8cb6e..33fc73dea1 100644 --- a/docs/development/checkpoint-implementation.md +++ b/docs/development/checkpoint-implementation.md @@ -57,7 +57,7 @@ The manual-commit strategy (`manual_commit*.go`) does not modify the active bran - **Location-independent transcript resolution** - transcript paths are always computed dynamically from the current repo location (via `agent.GetSessionDir` + `agent.ResolveSessionFile`), never stored in checkpoint metadata. This ensures log restore (`RestoreLogsOnly`) works after repo relocation or across machines. - **Token usage scoping** - `SessionState.TokenUsage` is the session-wide total used by `entire status`; `SessionState.CheckpointTokenUsage` is the pending checkpoint delta since the last condensation. Checkpoint token metadata must stay scoped to `SessionState.TokenStart()` (`TokenTranscriptStart`) or the pending checkpoint delta. `TokenTranscriptStart` is set only where tokens are stored or carried over, and moves back only when a condensation or attach records the end of a transcript that got shorter (rewritten or compacted): condensation (`AdvanceCheckpointWindow`, which leaves it alone when the transcript could not be read), attach (`ConsumeAttachTokenWindow`) and adopt (`SetTokenStart`); unlike `CheckpointTranscriptStart`, carry-forward and adopt never move it back and the turn-end advance after a mid-turn commit leaves it behind, so a checkpoint whose displayed window restarts at 0 still counts each token once and a mid-turn commit's post-commit tail lands in the next checkpoint. `session attach` counts from it too and records the transcript end afterwards (`strategy.AttachTokenUsage` / `ConsumeAttachTokenWindow`); adopt carries the source's offset, pending delta and subagent baseline because the retired source never condenses again. Attach sets `SessionState.TokenUsage` to the whole-transcript total for an idle or ended session, or for a running one whose agent exited (or, when liveness can't be checked, went quiet past `StuckActiveThreshold`); a running session keeps the hooks' total (its turn's Stop adds that turn) plus the cumulative subagent total; if a hook changed token state while attach waited at its prompt, attach leaves token state alone. Cursor tokens come only from stop-hook payloads, while Copilot CLI can also backfill full-session totals from `session.shutdown`. Condensation's full-session backfill recompute runs with `subagentsDir=""` and so drops `SubagentTokens`; `fillMissingSubagentTokensFrom` refills it from the already-rescoped `state.CheckpointTokenUsage`. The checkpoint-scoped recompute (`calculateLiveTranscriptTokenUsage`, counting from `TokenStart()`) reads the still-available subagent transcripts only when no checkpoint-scoped subagent total already exists, the agent supports that extraction, and a real subagent directory exists. It subtracts `SubagentTokensBaseline` for checkpoint metadata and keeps the cumulative snapshot on `state.TokenUsage` so the reset advances the next baseline; an empty delta stays nil. The scan is substantially more expensive for subagent-heavy sessions, so every gate is load-bearing. The store sums those scoped values across a checkpoint's sessions via `types.AddTokenUsage` (the single token-summing primitive — do not hand-roll another; a field-by-field copy is how the nested total came to be dropped in the first place). - Tracks session state in `.git/entire-sessions/` (shared across worktrees) -- **Commit-to-session linking is identity-first** (`strategy/session_identity.go`): identity comes from `SessionState.Owner`, the `proclive.Identity` that `captureSessionOwner` already records on every turn start (first non-transient ancestor — proclive skips shells, `entire` itself, and the Go toolchain, so a human commit typed in the same terminal never matches). Commit hooks snapshot their own ancestry once (`proclive.CurrentAncestry`) and match every candidate against it in memory (`Ancestry.Depth`) — one hostname/boot-id/proc walk per commit, not one per session state — linking the commit to the session whose agent process is an ancestor — in any worktree (nearest ancestor wins, so a nested agent beats the outer agent that spawned it, and only a tie at equal depth falls to the latest interaction; host/boot/start-time guards defeat PID reuse and cross-machine matches; Windows cannot introspect and falls back to worktree matching). The identity match is UNIONED with the worktree-matched set, never a replacement: a commit condenses every session with pending content in its worktree. Any session matched outside its home worktree is guest-linked — whether identity-matched or selected by the pre-existing single-worktree fallback — and is condensed and linked without mutating worktree-coupled state (`BaseCommit`) from the foreign worktree (`isSessionHomeWorktree`). Worktree matching is always computed (it is the sole mechanism for commits with no agent ancestry): imported sessions never link, and multi-worktree ambiguity is filtered to recently-interacting sessions (15 min) before declining. This deliberately turns some former ambiguity declines into a best-candidate link; `recentSessionWindow` is a correctness tradeoff because a session in a long-running build or tool call can age out and leave the other recent worktree to win. The stderr hint naming `entire session adopt` fires only from the commit-linking path, and only when identity matching could not rescue the commit either. **Squashes inherit their trailers**: when SQUASH_MSG exists in the per-worktree git dir, prepare-commit-msg copies its `Entire-Checkpoint` trailers into the message only when a staged path is one a commit Git recorded in that message changed (`inheritSquashedCheckpointTrailers`); a squash committed with `-m` reports `message`, not `squash`, and an abandoned squash's stale message is ignored. Inherited trailers are links: matching still runs and a session with pending work is stamped after them, and PostCommit condenses only into the trailer without a checkpoint (`pickCondensationTarget`, the last such one, since the stamp is appended) among those prepare did not record as inherited (`recordInheritedTrailers` / `takeInheritedTrailers`, a marker in the per-worktree git dir tied to the commit's parent), rechecks that target's existence before writing, and refuses to write into a checkpoint the session did not stamp or is not amending (`stampedByAnotherCommit`). **Redone commits inherit too** (`inheritReplacedCommitsTrailers`, `replacedCommits`): the dropped work is read from HEAD's reflog (per-worktree `logs/HEAD`, tail-bounded), skipping resets that did not move HEAD; dropped commits are those only the old tip reaches (`git rev-list ` excluding HEAD, remotes and every branch not pointing at the tip); a dropped commit is inherited when every staged file it changed matches the old tip byte for byte; commits since the reset keep the redo open only if each redid dropped work; an amend fold (`reset --soft` + `commit --amend`) inherits too, recorded against HEAD's parent. Staged content, mode and deletions come from one rename-free `git diff --cached --raw` of the commit index (`stagedBlobs`, keeping the hook's `GIT_INDEX_FILE`). A redone path is **held** (`redo.paths`) only when the newest dropped commit that changed it carries a checkpoint that is not deleted and exists in this clone's store (`capturedCheckpoints`) and the staged mode matches; inheriting stays permissive. A commit whose every staged path is held gets no fresh trailer; otherwise session matching sees only the redo's other staged paths (`redo.others`, the same rename-free view). Held paths ride in the inherited-trailers marker; a commit that ends up with no stamped trailer (a pure redo, or a partial redo whose other files are no session's work) drops them from the worktree's sessions' `FilesTouched` where the commit holds the recorded content (`reconcileRedoneFiles`), while a stamped trailer is condensed with overlap judged without the redo, so it always resolves. Under `go test`, `session.NewStateStore` and `NewStateStoreForWorktree` refuse to open outside the temp root so non-isolated tests fail loudly instead of leaking fixture sessions into a real repo. +- **Commit-to-session linking is identity-first** (`strategy/session_identity.go`): identity comes from `SessionState.Owner`, the `proclive.Identity` that `captureSessionOwner` already records on every turn start (first non-transient ancestor — proclive skips shells, `entire` itself, and the Go toolchain, so a human commit typed in the same terminal never matches). Commit hooks snapshot their own ancestry once (`proclive.CurrentAncestry`) and match every candidate against it in memory (`Ancestry.Depth`) — one hostname/boot-id/proc walk per commit, not one per session state — linking the commit to the session whose agent process is an ancestor — in any worktree (nearest ancestor wins, so a nested agent beats the outer agent that spawned it, and only a tie at equal depth falls to the latest interaction; host/boot/start-time guards defeat PID reuse and cross-machine matches; Windows cannot introspect and falls back to worktree matching). The identity match is UNIONED with the worktree-matched set, never a replacement: a commit condenses every session with pending content in its worktree. The exception is a worktree where no session is homed: there the fallback's sessions belong to other worktrees, so once ancestry identifies the committer they are dropped, and a session from elsewhere joins only through its record of this worktree; a commit with no identified committer (Windows, no recorded owner) still links the fallback's sessions. Any session matched outside its home worktree is guest-linked — whether identity-matched or selected by the pre-existing single-worktree fallback — and is condensed and linked without mutating worktree-coupled state (`BaseCommit`) from the foreign worktree (`isSessionHomeWorktree`). Worktree matching is always computed (it is the sole mechanism for commits with no agent ancestry): imported sessions never link, and multi-worktree ambiguity is filtered to recently-interacting sessions (15 min) before declining. This deliberately turns some former ambiguity declines into a best-candidate link; `recentSessionWindow` is a correctness tradeoff because a session in a long-running build or tool call can age out and leave the other recent worktree to win. The stderr hint naming `entire session adopt` fires only from the commit-linking path, and only when identity matching could not rescue the commit either. **Squashes inherit their trailers**: when SQUASH_MSG exists in the per-worktree git dir, prepare-commit-msg copies its `Entire-Checkpoint` trailers into the message only when a staged path is one a commit Git recorded in that message changed (`inheritSquashedCheckpointTrailers`); a squash committed with `-m` reports `message`, not `squash`, and an abandoned squash's stale message is ignored. Inherited trailers are links: matching still runs and a session with pending work is stamped after them, and PostCommit condenses only into the trailer without a checkpoint (`pickCondensationTarget`, the last such one, since the stamp is appended) among those prepare did not record as inherited (`recordInheritedTrailers` / `takeInheritedTrailers`, a marker in the per-worktree git dir tied to the commit's parent), rechecks that target's existence before writing, and refuses to write into a checkpoint the session did not stamp or is not amending (`stampedByAnotherCommit`). **Redone commits inherit too** (`inheritReplacedCommitsTrailers`, `replacedCommits`): the dropped work is read from HEAD's reflog (per-worktree `logs/HEAD`, tail-bounded), skipping resets that did not move HEAD; dropped commits are those only the old tip reaches (`git rev-list ` excluding HEAD, remotes and every branch not pointing at the tip); a dropped commit is inherited when every staged file it changed matches the old tip byte for byte; commits since the reset keep the redo open only if each redid dropped work; an amend fold (`reset --soft` + `commit --amend`) inherits too, recorded against HEAD's parent. Staged content, mode and deletions come from one rename-free `git diff --cached --raw` of the commit index (`stagedBlobs`, keeping the hook's `GIT_INDEX_FILE`). A redone path is **held** (`redo.paths`) only when the newest dropped commit that changed it carries a checkpoint that is not deleted and exists in this clone's store (`capturedCheckpoints`) and the staged mode matches; inheriting stays permissive. A commit whose every staged path is held gets no fresh trailer; otherwise session matching sees only the redo's other staged paths (`redo.others`, the same rename-free view). Held paths ride in the inherited-trailers marker; a commit that ends up with no stamped trailer (a pure redo, or a partial redo whose other files are no session's work) drops them from the worktree's sessions' `FilesTouched` where the commit holds the recorded content (`reconcileRedoneFiles`), while a stamped trailer is condensed with overlap judged without the redo, so it always resolves. Under `go test`, `session.NewStateStore` and `NewStateStoreForWorktree` refuse to open outside the temp root so non-isolated tests fail loudly instead of leaking fixture sessions into a real repo. - **Reclaiming sessions whose agent vanished** - not every agent fires a session-end hook, and any agent can be killed before its hook runs, so a session can be left un-finalized forever. `SessionState.Owner` — the same fingerprint commit linking matches above — is captured at every turn start by `captureSessionOwner`, and `State.OwnerExited()` reports it gone via `proclive.Check`. `finalizeExitedSessions` sweeps those inside `entire doctor` and `__sweep_sessions`, ending them exactly as a clean stop would. **Not `entire status`**: status is a read-only observer — it reads through `StateStore.ListReadOnly` and reports a dead-owner session as `exited` without finalizing it, because asking what is happening must not change what is happening. Status therefore shows such a session until doctor or the sweeper reaches it. **`OwnerExited` deliberately covers IDLE as well as ACTIVE** — an agent that finishes its last turn and then quits leaves IDLE, so gating on ACTIVE alone missed the common case; only already-finalized sessions are excluded, per the shared `State.IsEnded()` predicate. Liveness is Unknown on Windows and for cross-host state, where behaviour degrades to the `StuckActiveThreshold` timeout. Because the sweep runs inside interactive commands, its eager condensing is capped by `sweepCondenseBudget` across the whole sweep: every candidate is always marked ENDED (a single atomic rename — that is what stops `entire status` reporting it as live), while condensing runs only while the budget lasts, so a multi-day backlog drains over successive invocations instead of stalling one. Skipping a condense is the existing fail-open path — PostCommit retries, and `doctor` reports the session as "ended with uncondensed checkpoint data". - **Snapshot checkpoints** (`strategy.CreateSnapshotCheckpoint`, hidden `entire checkpoint create`) - for sessions that change no files, which otherwise never get a checkpoint: turn end skips `SaveStep` when nothing changed, so session end finds no steps and `PrepareSessionEndCondensation` marks the session condensed without writing. A session with pending file changes is refused (`ErrPendingFileChanges`); its next commit checkpoints that work. Pending changes come from `resolveFilesTouched`, not `FilesTouched` alone: most agents emit no per-tool file events, so `FilesTouched` stays empty until `SaveStep` at turn end, and an edit earlier in the current turn is visible only in the live transcript (paths outside the worktree, such as an agent's plan files, are dropped). An edit made through a shell command rather than an edit tool is seen by neither until turn end, so a snapshot in that turn is allowed and overlaps the next commit's checkpoint. The snapshot runs `CondenseSession` inside `MutateSessionState` and returns `ErrMutationSkip`: the lock is required because the per-session redaction prefix cache writes its payload and entry in two steps, and an unlocked writer racing a commit's condensation can leave the entry pointing at the other writer's payload, splicing duplicated or dropped lines into every later checkpoint; never saving keeps the session's window untouched, so repeated snapshots each cover the session from the same start and overlap one another (as carry-forward checkpoints already do; `tokens profile` discloses overlap). It passes `failOnRedactionError` (a snapshot exists for its transcript, so a redaction failure is an error rather than the hook paths' drop-and-continue). No skill telemetry is emitted, since unsaved state never marks the events persisted. - **Session-end hooks that run under a host deadline** - an agent whose session-end hook fires inside its own shutdown may be killed part-way through. Such agents declare `agent.SessionEndBudgeter` (Codex: 3s cap, process tree killed on expiry). The budget bounds **only** the eager condense in `endSessionNow`, so the cheap step that un-sticks the session is never the one given up on; it is set below the host cap with enough headroom for the shell wrapper and binary load, which happen before Entire's own clock starts. Unbounded is not the same as guaranteed, though: mark-ended runs under `MutateSessionState`, whose flock acquire blocks, so a concurrent condense holding the lock can push it past the cap and get the tree killed — the exited-owner sweep above is the backstop. The bound is best-effort (condensation doesn't poll ctx between stages), and being cut off costs duplication rather than data: an incomplete condense leaves `FullyCondensed` false and PostCommit retries, except in the window between the v1 checkpoint write and the state save, where the checkpoint is already committed and PostCommit mints a second one over the same transcript range.