fix: retire source session after external adoption · Entire
fix: retire source session after external adoption
3819557→main·
peyton-alt·2w ago·6 files·+169 added/-2 removed
Sessions
0ed46044b4c8View transcript
?\Session Adoption and Worktree Management FixesCodex·1 step
Changes
6
cmd/entire/cli
Mlifecycle.go+5
session
Mstate.go+10
Msession_adopt.go+22/-1
Msession_adopt_test.go+120/-1
241 unmodified lines
242
243
244
245
246
247
248
249
250
251
252
241 unmodified lines
// so ErrStateNotFound is the normal first-session path — only warn on
// genuinely unexpected errors, matching the rest of this file.
mutErr := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error {
if state.AdoptedIntoWorktreePath != "" {
logging.Info(logCtx, "skipping adopted-away source session start",
slog.String("adopted_into_worktree", state.AdoptedIntoWorktreePath))
return strategy.ErrMutationSkip
}
persistEventMetadataToState(event, state)
if transErr := strategy.TransitionAndLog(ctx, state, session.EventSessionStart, session.TransitionContext{}, session.NoOpActionHandler{}); transErr != nil {
logging.Warn(logCtx, "session start transition failed",
Mcmd/entire/cli/lifecycle.go+5
112 unmodified lines
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
112 unmodified lines
// Derived from .git/worktrees/<name>/, stable across git worktree move
WorktreeID string `json:"worktree_id,omitempty"`
// AdoptedIntoWorktreePath marks a source-side tombstone left behind after
// `entire session adopt` moves this session into another repository/worktree.
// Hook TurnStart must not reactivate tombstoned source records, otherwise the
// same session ID can diverge in two session stores.
AdoptedIntoWorktreePath string `json:"adopted_into_worktree_path,omitempty"`
// AdoptedIntoWorktreeID is the target worktree ID paired with
// AdoptedIntoWorktreePath when available.
AdoptedIntoWorktreeID string `json:"adopted_into_worktree_id,omitempty"`
// Branch is the git branch HEAD pointed at the last time this session took a
// turn. Captured on each turn start so it tracks branches created or renamed
// after the session began. Empty when HEAD was detached or for sessions
Mcmd/entire/cli/session/state.go+10
38 unmodified lines
39
40
41
42
42
43
44
45
126 unmodified lines
172
173
174
175
176
177
178
179
180
181
4 unmodified lines
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
235 unmodified lines
442
443
444
445
446
447
448
449
38 unmodified lines
Long: `Adopt an active session from another worktree into the current repository.
This is useful when an agent starts in one repository or worktree, then moves
and makes changes in another. Adoption copies the live session state into the
and makes changes in another. Adoption moves the live session state into the
current repo and seeds it with the current repo's uncommitted file changes so
the next commit can be linked normally.
126 unmodified lines
if err := targetStore.Save(ctx, next); err != nil {
return fmt.Errorf("save adopted session state: %w", err)
}
retired := retireAdoptedSourceSession(sourceState, next)
if err := sourceStore.Save(ctx, &retired); err != nil {
return fmt.Errorf("retire source session state: %w", err)
}
adopted = next
filesTouched = touched
return nil
4 unmodified lines
return adopted, filesTouched, nil
}
func retireAdoptedSourceSession(source, target *session.State) session.State {
now := time.Now()
retired := cloneAdoptSourceState(source)
retired.Phase = session.PhaseEnded
retired.EndedAt = &now
retired.FullyCondensed = true
retired.Owner = nil
retired.FilesTouched = nil
retired.TurnID = ""
retired.TurnCheckpointIDs = nil
retired.AdoptedIntoWorktreePath = target.WorktreePath
retired.AdoptedIntoWorktreeID = target.WorktreeID
return retired
}
func adoptFromSameSessionStore(ctx context.Context, sourceWorktree string, sourceState *session.State, opts adoptOptions) (*session.State, []string, error) {
if !opts.Force {
return nil, nil, fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", sourceState.SessionID)
}
235 unmodified lines
adopted.RealignAttributionBase(head.Hash().String())
adopted.WorktreePath = worktreeRoot
adopted.WorktreeID = worktreeID
adopted.AdoptedIntoWorktreePath = ""
adopted.AdoptedIntoWorktreeID = ""
adopted.Branch = branch
adopted.LastInteractionTime = &now
adopted.Phase = session.PhaseActive
Mcmd/entire/cli/session_adopt.go+22/-1
47 unmodified lines
48
49
50
51
51
52
53
54
75 unmodified lines
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
47 unmodified lines
}
}
func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) {
func TestSessionAdopt_MovesExternalSessionIntoCurrentWorktree(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)
75 unmodified lines
}
}
func TestSessionAdopt_ExternalStoreRetiresSourceSession(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)
sessionID := "test-adopt-external-retire-source"
lastInteraction := time.Now().Add(-1 * time.Minute)
sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName))
if err := sourceStore.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now().Add(-5 * time.Minute),
LastInteractionTime: &lastInteraction,
Phase: session.PhaseActive,
BaseCommit: testutil.GetHeadHash(t, sourceRepo),
AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo),
WorktreePath: sourceRepo,
LastPrompt: "continue work in target repo",
}); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n")
t.Chdir(targetRepo)
var out bytes.Buffer
err := runAdopt(context.Background(), &out, sessionID, adoptOptions{
FromWorktree: sourceRepo,
Force: true,
})
if err != nil {
t.Fatalf("runAdopt failed: %v", err)
}
targetStore, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
adopted, err := targetStore.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if adopted == nil {
t.Fatal("expected adopted target session state")
}
if adopted.Phase != session.PhaseActive || adopted.EndedAt != nil {
t.Fatalf("target state Phase/EndedAt = %q/%v, want active/nil", adopted.Phase, adopted.EndedAt)
}
sourceAfter, err := sourceStore.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if sourceAfter == nil {
t.Fatal("expected source session state to remain as a retired record")
}
if sourceAfter.Phase != session.PhaseEnded {
t.Fatalf("source Phase = %q, want ended", sourceAfter.Phase)
}
if sourceAfter.EndedAt == nil {
t.Fatal("source EndedAt = nil, want retirement timestamp")
}
if isAdoptableSourceSession(sourceAfter) {
t.Fatalf("source state remains adoptable after external adoption: %#v", sourceAfter)
}
t.Chdir(sourceRepo)
sourceAgent := &mockLifecycleAgent{name: agent.AgentNameClaudeCode, agentType: agent.AgentTypeClaudeCode}
if err := handleLifecycleSessionStart(context.Background(), sourceAgent, &agent.Event{
Type: agent.SessionStart,
SessionID: sessionID,
}); err != nil {
t.Fatalf("SessionStart in the adopted-away source repo should no-op without disrupting the hook, got: %v", err)
}
sourceAfterSessionStart, err := sourceStore.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if sourceAfterSessionStart == nil {
entries, readErr := os.ReadDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName))
if readErr != nil {
t.Fatalf("source state disappeared after SessionStart; read state dir: %v", readErr)
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
t.Fatalf("source state disappeared after SessionStart; state dir contains %v", names)
}
if sourceAfterSessionStart.Phase != session.PhaseEnded {
t.Fatalf("source Phase after SessionStart = %q, want ended", sourceAfterSessionStart.Phase)
}
if sourceAfterSessionStart.EndedAt == nil {
t.Fatal("source EndedAt after SessionStart = nil, want retirement timestamp")
}
err = strategy.NewManualCommitStrategy().InitializeSession(
context.Background(),
sessionID,
agent.AgentTypeClaudeCode,
"",
"source prompt after adoption",
"",
)
if err != nil {
t.Fatalf("InitializeSession in the adopted-away source repo should no-op without disrupting the hook, got: %v", err)
}
sourceAfterTurnStart, err := sourceStore.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if sourceAfterTurnStart.Phase != session.PhaseEnded {
t.Fatalf("source Phase after rejected TurnStart = %q, want ended", sourceAfterTurnStart.Phase)
}
if sourceAfterTurnStart.EndedAt == nil {
t.Fatal("source EndedAt after rejected TurnStart = nil, want retirement timestamp")
}
}
func TestSessionAdopt_ClearsSourceOwner(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)