Harden session adoption state replacement · Entire
Harden session adoption state replacement
Sessions
296b9c8f3d4cView transcript
Changes
2
cmd/entire/cli
Msession_adopt.go+72/-11
Msession_adopt_test.go+116/-2
5 unmodified lines
6
7
8
9
10
11
12
13
14
15
24 unmodified lines
40
41
42
41
43
44
45
46
47
43
48
49
50
51
52
47 unmodified lines
100
101
102
97
103
104
105
106
10 unmodified lines
117
118
119
114
115
116
117
118
119
120
120
121
122
180 unmodified lines
303
304
305
307
306
307
308
309
38 unmodified lines
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
5 unmodified lines
"errors"
"fmt"
"io"
"maps"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"time"
24 unmodified lines
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
current repo and seeds it with the current repo's uncommitted file changes so
the next commit can be linked normally.
When the source and target share a Git session store, adoption moves the same
session state file to the current worktree and requires --force or --yes.
Example: ` entire session adopt 019ed5fe-ec49-7a72-89fd-f38e323f5448 --from ../cli`
entire session adopt --from /path/to/source/worktree
entire session adopt --from /path/to/source/worktree --yes
```go
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
sessionID := ""
}
func canReplaceAdoptState(existing, source *session.State, sameSessionStore bool) bool {
return sameSessionStore &&
existing != nil &&
source != nil &&
existing.SessionID == source.SessionID
}
func validateAdoptSourceTranscript(source *session.State, sourceWorktree string) error {
if source == nil || strings.TrimSpace(source.TranscriptPath) == "" {
return nil
}
now := time.Now()
adopted := *source
adopted := cloneAdoptSourceState(source)
// Keep the source live transcript path. In cross-repo adoption the transcript
// belongs to the continuing agent session, not the target repository; clearing
...
return &adopted, filesTouched, nil
}
func cloneAdoptSourceState(source *session.State) session.State {
adopted := *source
adopted.EndedAt = cloneTimePtr(source.EndedAt)
adopted.LastInteractionTime = cloneTimePtr(source.LastInteractionTime)
adopted.ReviewSkills = slices.Clone(source.ReviewSkills)
adopted.TurnCheckpointIDs = slices.Clone(source.TurnCheckpointIDs)
adopted.UntrackedFilesAtStart = slices.Clone(source.UntrackedFilesAtStart)
adopted.FilesTouched = slices.Clone(source.FilesTouched)
adopted.TokenUsage = cloneTokenUsage(source.TokenUsage)
adopted.SkillEvents = cloneSkillEvents(source.SkillEvents)
adopted.PromptAttributions = clonePromptAttributions(source.PromptAttributions)
if source.PendingPromptAttribution != nil {
pending := clonePromptAttribution(*source.PendingPromptAttribution)
adopted.PendingPromptAttribution = &pending
}
return adopted
}
func cloneTimePtr(t *time.Time) *time.Time {
if t == nil {
return nil
}
cloned := *t
return &cloned
}
...
func TestSessionAdopt_CloneSourceStateDoesNotShareMutableFields(t *testing.T) {
lastInteraction := time.Now().Add(-1 * time.Minute)
endedAt := time.Now()
source := &session.State{
SessionID: "test-adopt-deep-copy",
StartedAt: time.Now().Add(-5 * time.Minute),
EndedAt: &endedAt,
LastInteractionTime: &lastInteraction,
ReviewSkills: []string{"/review"},
TurnCheckpointIDs: []string{"source-checkpoint"},
UntrackedFilesAtStart: []string{"untracked.txt"},
FilesTouched: []string{"source.txt"},
TokenUsage: &agent.TokenUsage{
InputTokens: 1,
SubagentTokens: &agent.TokenUsage{
OutputTokens: 2,
},
},
SkillEvents: []agent.SkillEvent{
{
ID: "skill-event",
TranscriptAnchor: &agent.SkillEventTranscriptAnchor{
EntryIDs: []string{"entry-1"},
},
Native: map[string]string{"tool": "skill"},
},
},
PromptAttributions: []session.PromptAttribution{
{
UserAddedPerFile: map[string]int{"source.txt": 1},
UserRemovedPerFile: map[string]int{"source.txt": 2},
},
},
PendingPromptAttribution: &session.PromptAttribution{
UserAddedPerFile: map[string]int{"pending.txt": 3},
UserRemovedPerFile: map[string]int{"pending.txt": 4},
},
}
adopted := cloneAdoptSourceState(source)
*adopted.EndedAt = endedAt.Add(1 * time.Hour)
*adopted.LastInteractionTime = lastInteraction.Add(1 * time.Hour)
adopted.ReviewSkills[0] = "/changed"
adopted.TurnCheckpointIDs[0] = "changed-checkpoint"
adopted.UntrackedFilesAtStart[0] = "changed-untracked.txt"
adopted.FilesTouched[0] = "changed-source.txt"
adopted.TokenUsage.SubagentTokens.OutputTokens = 99
adopted.SkillEvents[0].TranscriptAnchor.EntryIDs[0] = "changed-entry"
adopted.SkillEvents[0].Native["tool"] = "changed-skill"
adopted.PromptAttributions[0].UserAddedPerFile["source.txt"] = 99
adopted.PromptAttributions[0].UserRemovedPerFile["source.txt"] = 99
adopted.PendingPromptAttribution.UserAddedPerFile["pending.txt"] = 99
adopted.PendingPromptAttribution.UserRemovedPerFile["pending.txt"] = 99
if !source.EndedAt.Equal(endedAt) {
t.Fatalf("source EndedAt was mutated: %v", source.EndedAt)
}
if !source.LastInteractionTime.Equal(lastInteraction) {
t.Fatalf("source LastInteractionTime was mutated: %v", source.LastInteractionTime)
}
if source.ReviewSkills[0] != "/review" {
t.Fatalf("source ReviewSkills = %v, want unchanged", source.ReviewSkills)
}
if source.TurnCheckpointIDs[0] != "source-checkpoint" {
t.Fatalf("source TurnCheckpointIDs = %v, want unchanged", source.TurnCheckpointIDs)
}
if source.UntrackedFilesAtStart[0] != "untracked.txt" {
t.Fatalf("source UntrackedFilesAtStart = %v, want unchanged", source.UntrackedFilesAtStart)
}
if source.FilesTouched[0] != "source.txt" {
t.Fatalf("source FilesTouched = %v, want unchanged", source.FilesTouched)
}
if source.TokenUsage.SubagentTokens.OutputTokens != 2 {
t.Fatalf("source TokenUsage.SubagentTokens.OutputTokens = %d, want unchanged", source.TokenUsage.SubagentTokens.OutputTokens)
}
if source.SkillEvents[0].TranscriptAnchor.EntryIDs[0] != "entry-1" {
t.Fatalf("source SkillEvents entry IDs = %v, want unchanged", source.SkillEvents[0].TranscriptAnchor.EntryIDs)
}
if source.SkillEvents[0].Native["tool"] != "skill" {
t.Fatalf("source SkillEvents native = %v, want unchanged", source.SkillEvents[0].Native)
}
if source.PromptAttributions[0].UserAddedPerFile["source.txt"] != 1 {
t.Fatalf("source PromptAttributions user added = %v, want unchanged", source.PromptAttributions[0].UserAddedPerFile)
}
if source.PromptAttributions[0].UserRemovedPerFile["source.txt"] != 2 {
t.Fatalf("source PromptAttributions user removed = %v, want unchanged", source.PromptAttributions[0].UserRemovedPerFile)
}
if source.PendingPromptAttribution.UserAddedPerFile["pending.txt"] != 3 {
t.Fatalf("source PendingPromptAttribution user added = %v, want unchanged", source.PendingPromptAttribution.UserAddedPerFile)
}
if source.PendingPromptAttribution.UserRemovedPerFile["pending.txt"] != 4 {
t.Fatalf("source PendingPromptAttribution user removed = %v, want unchanged", source.PendingPromptAttribution.UserRemovedPerFile)
}
}
func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)
193 unmodified lines
...
}