Skip to content

Let a session follow its agent into the worktree it commits in - #2531

Open
peyton-alt wants to merge 31 commits into
mainfrom
peyton/session-attach-worktrees
Open

peyton-alt wants to merge 31 commits into
mainfrom
peyton/session-attach-worktrees

Conversation

@peyton-alt

@peyton-alt peyton-alt commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

https://entire.io/gh/entireio/cli/trails/1378

Users working across worktrees had to run entire session attach because 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 at session 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.

  • prepare-commit-msg reserves the stamped checkpoint ID on each session, once the message write has succeeded.
  • post-commit then links the commit through that reservation even when paths and ancestry differ between the two hooks (reserveCheckpointForStampedSessions, sessionsIncludingReservedFor).

Refusals are visible. When linking declines, it names the candidate sessions and worktrees on the terminal, with session attach as the remedy (announceUnlinkedCommit). This only runs from prepare-commit-msg.

The hook follows the agent's working directory.

  • Agents report where they work on every hook payload (Event.CWD). Claude Code, Codex, Copilot, Factory Droid and pi all send one.
  • When it names another worktree of the same repository where Entire is enabled, the hook process moves there before resolving anything, and its log sink moves with it (followAgentWorkingDirectory).
  • Turn start and turn end then re-home the session, so no process ancestry is needed. This also covers Windows, where ancestry can't be read.
  • Re-homing happens only on a strong signal: a turn boundary whose payload named the tree, or edits captured there. A hook without a cwd never 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.

  • End hooks find the pre-prompt and pre-task baselines where the turn started (SessionState.TurnWorktreePath) or at the session's home, and consume them there (state_baseline.go).
  • Only this turn's prompt.txt entry is carried over; earlier turns' prompts stay with the steps saved at home.
  • A baseline from another tree can't tell that tree's pre-existing untracked files from new ones. New-file detection then falls back to the transcript, the same as when the untracked scan is skipped. Files created only by shell commands the transcript never names are missed rather than wrongly claimed.
  • MutateSessionState records 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.
  • A turn end that saved no step, such as a subagent-only turn, re-homes as well.

session attach and session adopt behave as before. Adopt now records the carried-over content as pending in the target worktree.

Testing

  • Integration tests for every re-home path:
    • own commit;
    • payload cwd;
    • signal-less hooks in the launch directory;
    • agents without a cwd;
    • a mid-turn move, including which prompt is carried;
    • a task that crosses trees;
    • a task entirely in the worktree;
    • Codex tool-use files recorded in the worktree and in both trees.
  • Three agents launched from main, each committing in its own worktree X, Y, Z (real binary, the commit made under the agent's own process): Y's checkpoint now holds only Y's session, mid-turn and after a turn, with the agents editing the same or different paths. main condenses all three sessions into it.
  • Unit tests for the follower's decisions, the location-aware guard, Droid/Copilot cwd parsing, and adopt.
  • mise run check green.
  • A real-binary scenario with installed git hooks, fresh multi-worktree repos and detached hooks with no agent ancestry. 17 checks across Claude Code (mid-turn move, cross-tree task, worktree-only task, both-tree guard, no-cwd guard) and Factory Droid all pass on this head; 9 of them fail on the pre-fix binary.

Follow-ups

  • Agents that send no working directory still rely on own-commit and captured-edit re-homing: Cursor (only workspace_roots), OpenCode and the external-agent protocol. OpenCode child sessions and an external-protocol cwd field are tracked separately.
  • An interactive chooser for a human committing in a third tree.
  • A commit with no terminal and no agent ancestry (a GUI client or a script) is still treated as an agent commit: the fallback's candidates all condense into it. Restricting that changes how those commits link, so it is a separate change.
  • Antigravity (merged since) sends no working directory, so it relies on own-commit re-homing like Cursor and OpenCode.

🤖 Generated with Claude Code

@peyton-alt
peyton-alt requested a review from a team as a code owner September 18, 2026 21:40
Copilot AI lite review requested due to automatic review settings September 18, 2026 21:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.WriteFile fails, 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, as addTrailerForAgentCommit already does.
	reserveCheckpointForStampedSessions(ctx, sessionsWithContent, checkpointID)

cmd/entire/cli/strategy/manual_commit_hooks.go:522

  • This persists a CondensationAttempt before the message is written, but the no-trailer path in PostCommit returns 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.go makes OwnerLiveness unknown there, so idleDeadOwner.OwnerExited() is false and isOrphanedSessionState correctly keeps this state. The test nevertheless unconditionally requires idle-dead-owner to 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 leaves FilesTouched in the state while it preserves the home worktree (see manual_commit_hooks.go:1585-1593). Therefore, after a normal foreign-worktree commit has successfully condensed, this guard still sees the files just consumed and returns false, 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit deeab2b. Configure here.

peyton-alt and others added 10 commits September 22, 2026 18:01
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
@peyton-alt
peyton-alt force-pushed the peyton/session-attach-worktrees branch from b668e5c to 1ca0a3b Compare September 23, 2026 16:33
peyton-alt and others added 13 commits September 23, 2026 09:48
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
peyton-alt and others added 8 commits September 24, 2026 17:26
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants