Let a session follow its agent into the worktree it commits in - #2531
peyton-alt wants to merge 31 commits into
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
Five moderate findings remain unresolved in reservation, cleanup, portability, and re-homing behavior.
Pull request overview
This PR improves session linking across Git worktrees, including re-homing, trailer-based recovery, and safer cleanup.
Changes:
- Re-homes sessions after agent commits in foreign worktrees.
- Reserves checkpoint IDs for reliable post-commit linking.
- Preserves live idle sessions and improves ambiguity guidance.
- Adds documentation and integration coverage.
File summaries
| File | Summary |
|---|---|
docs/development/checkpoint-implementation.md |
Documents implementation behavior. |
docs/architecture/sessions-and-checkpoints.md |
Documents cross-worktree session behavior. |
cmd/entire/cli/strategy/session_rehome_test.go |
Tests re-homing guards. |
cmd/entire/cli/strategy/session_identity.go |
Implements linking and re-homing logic. Moderate finding: consumed files can prevent re-homing. |
cmd/entire/cli/strategy/session_identity_test.go |
Tests linking announcements. |
cmd/entire/cli/strategy/manual_commit_test.go |
Tests cleanup behavior. Moderate finding: cleanup expectation is not portable. |
cmd/entire/cli/strategy/manual_commit_session.go |
Updates orphan detection. Moderate finding: pending reservations may be deleted. |
cmd/entire/cli/strategy/manual_commit_hooks.go |
Adds trailer reservation and post-commit handling. Two moderate findings concern stale or uncancellable reservations. |
cmd/entire/cli/integration_test/worktree_rehome_test.go |
Tests foreign-worktree re-homing. |
cmd/entire/cli/integration_test/testenv.go |
Supports linked-worktree commit-message files. |
cmd/entire/cli/integration_test/session_state_idle_survives_test.go |
Tests idle-session preservation. |
cmd/entire/cli/integration_test/postcommit_reserved_trailer_test.go |
Tests reserved-trailer recovery. |
Review details
Suppressed comments (5)
cmd/entire/cli/strategy/manual_commit_hooks.go:522
- The reservation is persisted before the commit-message write below. If
os.WriteFilefails, this hook returns nil and Git can still create the commit without the trailer, leaving a pending checkpoint ID that no commit names; a later commit can then reuse that stale reservation. Move this call until after the message write succeeds, asaddTrailerForAgentCommitalready does.
reserveCheckpointForStampedSessions(ctx, sessionsWithContent, checkpointID)
cmd/entire/cli/strategy/manual_commit_hooks.go:522
- This persists a
CondensationAttemptbefore the message is written, but the no-trailer path inPostCommitreturns without clearing it when the user removes the trailer (the documented opt-out flow). The next turn/doctor can then reuse or condense this reserved ID even though the user explicitly declined linkage, so the reservation needs a durable way to be cancelled when its stamped trailer is absent while still preserving genuine interrupted condensations.
reserveCheckpointForStampedSessions(ctx, sessionsWithContent, checkpointID)
cmd/entire/cli/strategy/manual_commit_session.go:195
- The orphan predicate does not account for
PendingCondensationID(). A newly stamped session can therefore be IDLE, have no shadow branch or task records, and be deleted as soon as its owner exits (or a later hook observes it), losing the reserved checkpoint that the new trailer-based recovery depends on. Pending condensation attempts should be retained or handled through the recovery path before applying this owner-exited cleanup.
func isOrphanedSessionState(state *SessionState) bool {
if state.Phase.IsActive() || !state.LastCheckpointID.IsEmpty() || state.HasTaskContent() {
return false
}
if state.Phase == session.PhaseIdle {
return state.OwnerExited()
cmd/entire/cli/strategy/manual_commit_test.go:391
- This test is not portable to the platforms supported by
proclive:proc_other.gomakesOwnerLivenessunknown there, soidleDeadOwner.OwnerExited()is false andisOrphanedSessionStatecorrectly keeps this state. The test nevertheless unconditionally requiresidle-dead-ownerto have been deleted, causing failures on Windows and other unsupported platforms. Make the expected cleanup conditional on the owner check, just as the live-owner fixture already is.
// Verify stale sessions were actually cleared from disk
for _, staleID := range []string{"idle-dead-owner", "stale-ended"} {
cmd/entire/cli/strategy/session_identity.go:176
postCommitProcessSessionLocked's guest-condensation path intentionally leavesFilesTouchedin the state while it preserves the home worktree (seemanual_commit_hooks.go:1585-1593). Therefore, after a normal foreign-worktree commit has successfully condensed, this guard still sees the files just consumed and returnsfalse, so sessions with tracked-file content are never re-homed and later non-agent commits remain ambiguous. Distinguish files still pending in the old home from files consumed by this commit (for example, carry that pre/post-condensation fact or compare with the committed file set) before applying this guard, and update the consumed list when the move succeeds.
if len(state.FilesTouched) > 0 || state.StepCount > 0 || state.HasTaskContent() {
- Files reviewed: 12/12 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit deeab2b. Configure here.
| state.UntrackedFilesAtStart = untracked | ||
| } | ||
| return sessions, nil | ||
| captureSessionBranch(repo, state) |
There was a problem hiding this comment.
Rehome keeps stale attribution snapshots
Medium Severity
rehomeSessionAfterOwnCommit moves BaseCommit and realigns the attribution base after a guest condensation, but leaves PendingPromptAttribution and PromptAttributions in place. Turn-start always records that snapshot against the old home, and guest condensation already includes it when attributing the commit. The home condensation path clears both fields after a base move because those values have been consumed; without the same reset, the next SaveStep or condensation on the new home reapplies old-home user-line counts.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit deeab2b. Configure here.
A session is homed where its first turn-start hook ran, and agent hooks run where the agent was launched. An agent started in the main checkout that then works in a worktree — created during the session and entered with the agent's worktree tool or a plain cd — stayed homed in the parent while every edit and commit landed elsewhere. Each commit there depended on the ancestry rescue, a commit from any other process fell into the multi-worktree refusal, and the refusal's hint went to stderr, which the installed hook wrappers discard, and recommended `session adopt`, which moves a live session. In a clone with several agents running, "always run session attach" was the result. Three changes to the linking layer: - After a commit condensed the session that process ancestry identified in a worktree that is not its home, re-home the session there (WorktreePath, WorktreeID, BaseCommit, attribution base, untracked baseline, branch), unless the old home still holds pending content. The worktree's own commits then link by exact match, including ones made by a process that is not the agent's descendant. - Reserve the trailer's checkpoint ID on each stamped session in prepare-commit-msg, and let post-commit add the sessions reserving the trailer's ID to its linking set, so a commit is condensed even when paths and ancestry differ between the two hooks instead of leaving a trailer nobody wrote. - When the fallback declines, name the candidate sessions and worktrees on the controlling terminal and stderr with `entire session attach <session-id>` as the remedy. The integration harness now writes the hook's commit-message scratch file outside the repository so the commit helpers work inside a linked worktree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M2TZ1SDK11TV37KJVT90ZD6Y
Adoption clears the reservation on the live copy and retires the source as an ended, fully-condensed tombstone that PostCommit skips anyway, so a tombstone's stale reservation must not condense stale state. The residual window is one git commit with a user command inside it; such a commit is logged as unclaimed and attach links it afterwards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M2VDEHX6JS8WNBRTTHK3XKAG
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M2VDVFW6KTDN62EDDB23H04S
The slow path reserved before writing the message, so a failed write left the session holding a reservation for a trailer no commit carries; the next unrelated commit would reuse it. The fast path already reserved after the write. A read-only message file now pins the ordering. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M2VEJPGM196BJJBRQW7JTMK3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M2VG6MM11EVRZ1Q30K06BCD9
findCommitLinkingSet decided the set was empty before post-commit applied the trailer's reservation, so a commit rescued by the reservation was announced as unlinked and then condensed in the same hook run. The reservation now folds into the set inside findCommitLinkingSet, and only prepare-commit-msg (no stamped trailer yet) announces. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M2VHKGF16F67YGN3KJ4C6CZD
Agents report the directory they work in on every hook payload; Claude Code's cwd follows EnterWorktree and cd. Hooks run where the agent was launched, so a session started in the main checkout stayed homed there while its work landed in a worktree, and the first commit after the move depended on process ancestry — unavailable on Windows. Every lifecycle payload now carries the working directory (Claude Code, Codex, Gemini, Copilot, pi). When it names another worktree of the same repository, the dispatcher moves the hook process there before anything is resolved, and turn-start and turn-end re-home the session under the same pending-content guard as the commit-time re-home. The first commit after a move finds a correctly homed session, and the Stop hook captures the files where the agent actually edited them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M32T558P9E8A1T8SPP336T9K
The metadata guard rejects raw git rev-parse queries. worktreeRootOf now walks up from the payload directory asking gitrepo.ResolveWorktreeMetadata, which also handles a cwd inside a subdirectory, and compares common dirs with symlinks resolved. Also note that a reservation left by an aborted commit is reused by the session's next commit, as an interrupted condensation's is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M32WB2P7K1QSG5X503DHH3Q4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M350TX0JA7CAK99BKJM8YC54
A hook that merely runs in the launch directory used to move the session there on every turn. Agents whose payloads carry no working directory (Cursor, Factory Droid, OpenCode, external agents) would pull a session back to the parent after its own commit had re-homed it into a worktree. Hooks now re-home only when the payload named the tree the hook runs in (strategy.WithAgentWorkingTree, set by the dispatcher's follower) or the turn-end capture found edits there. The own-commit re-home is unchanged. A payload cwd that is not an existing directory confirms nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M351R94PR60TS15MBN306ARF
b668e5c to
1ca0a3b
Compare
The logger is bound to the launch worktree before the hook follows the agent, so everything the hook did in the other worktree was logged in the launch worktree's .entire/logs. Rebind it after the move. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M37JPWBMP4VYD6GDM805TNYR
The launch worktree passed the hooks' enablement gate; the worktree the payload names had not. Following into a never-enabled worktree would set Entire up there: logs, session state, checkpoints. Run the same gate against the target after moving, and move back when it fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M37KKQA2GMHCVQ406798FE28
The hook envelope already parsed cwd, but none of the event builders set Event.CWD, so the hook never followed a Copilot session into a worktree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M37NA08FVNG28AYP4KHG9TAR
The follower moved first and rolled back when the target turned out not to be enabled, and a failed rollback would have left the hook running in the unenabled worktree. Ask the target's settings by path instead, so a hook that does not qualify never leaves the launch worktree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Entire-Checkpoint: 01M37P3XWQHCGK4TXQ3685KNV6
Entire-Checkpoint: 01M388FEX3795DGXRBJZ99Y20G
Every Droid hook payload carries the agent's working directory; parse it on session, turn and subagent events so the dispatcher follows Droid like the other agents that send one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M38P6QV9T126T7B8DWJCWAMS
Hooks follow the agent's working directory on every event, so a turn or task can start in one worktree and end in another. Make that complete: - End hooks find the pre-prompt and pre-task baselines in the worktree the turn started in (SessionState.TurnWorktreePath) or the session's home, consume them there and carry the turn's prompt.txt. A baseline scanned in another tree cannot tell this tree's pre-existing untracked files from new ones, so new-file detection falls back to the transcript instead of claiming them. - MutateSessionState stamps PendingContentWorktree whenever a mutation adds files or task records, so the re-home guard no longer pins a session to a home that holds none of its pending work; shadow steps still always do. - A turn end that saved no step (a subagent-only turn) re-homes too. - Only turn boundaries confirm that the parent session itself moved; task and tool-use events follow for their own capture. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M38P78B7JZDR8G45X00K10C0
Copilot CLI now joins its subagent stop to the launch in the parent transcript and drops the event when that fails, so the empty-ID merge is only a fallback for agents that send no correlation ID at all. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M38P7NDE94KCC1AB1K3QMNKV
Adoption clones the source state, which carried the source's turn and pending-content worktrees into the target. Clear the turn location and record the carried-over content as pending in the target. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M38QNZA4D7AMX77J4T1NGMH7
prompt.txt accumulates prompts until condensation, so moving the whole file duplicated earlier turns' prompts into the new tree's checkpoint and took them from the steps still saved at home. The pre-prompt baseline records the file's size at turn start; the end hook carries only what was appended after it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M38QP10TC4FFK3K9VQS1K0VF
…-worktrees # Conflicts: # cmd/entire/cli/agent/geminicli/lifecycle.go
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3AZ92AEH0TMX2MDXC0MQF54
… allocation-free The unlinked-commit notice fired on any human commit made while sessions lived in two or more other worktrees, even idle ones holding nothing this commit stages. It now names only candidates with new content in the commit. The pending-content snapshot built two maps on every mutation, PostToolUse included. It now keeps the FilesTouched slice header and a digest of the task records, allocating only when FilesTouched was replaced by a same-length list. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3AZ941B4P8RVKAXXYZT1962
…/cli into peyton/session-attach-worktrees
… be read A baseline copy that could not be read (permissions, symlink policy, a transient I/O error) ended the whole multi-worktree lookup, and the caller then had no baseline at all, so every untracked file counted as new: the over-capture the lookup exists to prevent. The search now skips that copy and tries the next worktree; when a baseline exists but none can be read, the loaders return it marked unreadable, which disables status-based new-file detection. The TodoWrite checkpoint hook applies the same guard. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3CM9ZVMAY17EYE0XT1NGNZ2
…-worktrees # Conflicts: # cmd/entire/cli/lifecycle.go # cmd/entire/cli/state.go # cmd/entire/cli/strategy/manual_commit_hooks.go
#2573 added an identical helper to the integration package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3CMXPNKPVD9XB99C1PHP9RF
…safe carryTurnPrompt trims the source worktree's prompt.txt, so it now opens that .entire with the write-capable opener, and logs when prompt.txt shrank since turn start (condensed mid-turn) instead of silently carrying all of it. The follower's process-wide chdir gets a comment on why it is safe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3CP4JX8C19Q88S439HTQZQP
…-worktrees # Conflicts: # docs/architecture/sessions-and-checkpoints.md # docs/development/checkpoint-implementation.md
With main's latest changes PostCommit crossed the maintainability-index limit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3CTY0DH9773GNN5KE3HQT6K
…ktree Several agents launched from one checkout, each working in its own worktree, are all still homed in that checkout until their first turn ends. A mid-turn commit by agent Y in its worktree found no session homed there, so the other-worktree fallback offered all of them, and the union with Y's identity-matched session condensed X's and Z's work into Y's checkpoint. When ancestry names the committing agent, the fallback is now skipped: only sessions actually homed in the commit's worktree join the identified one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M3DKT1943ANAFQ104H65VWRJ


https://entire.io/gh/entireio/cli/trails/1378
Users working across worktrees had to run
entire session attachbecause their commits weren't linked. This happened whenever an agent was launched in the main checkout and then worked in a worktree. The cause: a session is homed once, where its first turn-start hook ran, and never moves. Hooks run where the agent was launched, so the session's home and the actual work diverge. Commit linking then relied on process ancestry to find the session, silently refused when several worktrees had live sessions, and pointed users atsession adopt, which moves a live session.This PR makes the session follow its agent between worktrees of one repository.
Split out: idle-session survival is #2573, squash trailer retention is #2574, and redo (reset) trailer retention is #2582.
Changes
The session follows its agent's commits. When a commit in another worktree condenses the session that process ancestry identified, the session is re-homed there. The exception is when the old home still holds pending content (
rehomeSessionAfterOwnCommit).The trailer identifies its sessions.
reserveCheckpointForStampedSessions,sessionsIncludingReservedFor).Refusals are visible. When linking declines, it names the candidate sessions and worktrees on the terminal, with
session attachas the remedy (announceUnlinkedCommit). This only runs from prepare-commit-msg.The hook follows the agent's working directory.
Event.CWD). Claude Code, Codex, Copilot, Factory Droid and pi all send one.followAgentWorkingDirectory).cwdnever pulls a session back.An identified agent's commit links only its own session. When process ancestry names the committing agent, the other-worktree fallback is skipped and only sessions actually homed in the commit's worktree join it. Several agents launched from one checkout, each in its own worktree, are all still homed in that checkout until their first turn ends, so the fallback used to give one agent's mid-turn commit to all of them.
Subagents and tool use follow too, without mis-attributing work. A turn or task can start in one worktree and end in another.
SessionState.TurnWorktreePath) or at the session's home, and consume them there (state_baseline.go).prompt.txtentry is carried over; earlier turns' prompts stay with the steps saved at home.MutateSessionStaterecords the hook's worktree whenever a mutation adds files or task records (PendingContentWorktree). Pending work recorded in the tree the session moves to no longer pins it to its old home; shadow-branch steps still always do.session attachandsession adoptbehave as before. Adopt now records the carried-over content as pending in the target worktree.Testing
cwd;cwd;cwdparsing, and adopt.mise run checkgreen.cwdguard) and Factory Droid all pass on this head; 9 of them fail on the pre-fix binary.Follow-ups
workspace_roots), OpenCode and the external-agent protocol. OpenCode child sessions and an external-protocolcwdfield are tracked separately.🤖 Generated with Claude Code