trail: add resume command · Entire
Log in
trail: add resume command
894aa84→main·
peyton-alt·3w ago·21 files·+2,231 added/-53 removed
Add trail resume with checkpoint-session discovery, interactive session selection, agent resume launching, and integration coverage for restoring sessions when local state is missing.
Sessions
33d13fdff5abView transcript
?\ Add Trail Resume SubcommandCodex·GPT-5.5·30 steps
Changes
21
cmd/entire/cli
agent
Magent.go+9
claudecode
Mclaude.go+8
codex
Mcodex.go+8
Mcodex_test.go+23
copilotcli
Mcopilotcli.go+9
factoryaidroid
Mfactoryaidroid.go+9
Aforeground.go+23
geminicli
Mgemini.go+8
opencode
Mopencode.go+16
pi
Mpi.go+17
Mregistry.go+11
Mregistry_test.go+35
integration_test
Atrail_resume_test.go+244
Mresume.go+88/-34
Mresume_picker.go+5/-2
strategy
Mmanual_commit_rewind.go+71/-10
Mrewind_test.go+31/-3
Mstrategy.go+6/-4
Mtrail_cmd.go+1
Atrail_resume_cmd.go+1023
Atrail_resume_cmd_test.go+586
291 unmodified lines
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
291 unmodified lines
LaunchCmd(ctx context.Context, initialPrompt string) (*exec.Cmd, error)
}
// ResumeLauncher is implemented by agents that `entire` can subprocess-spawn
// to continue an existing session.
//
// Contract matches Launcher: the returned command is foreground-ready with
// stdio wired to the caller's terminal, and callers run it directly.
type ResumeLauncher interface {
LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error)
}
// DiscoveredSkill describes one review-adjacent skill found on disk by a
// SkillDiscoverer. Name is the agent-native invocation form (e.g. a
// slash-prefixed command); Description is scraped from on-disk metadata
Mcmd/entire/cli/agent/agent.go+9
387 unmodified lines
388
389
390
391
392
393
394
395
396
397
398
387 unmodified lines
cmd.Env = os.Environ()
return cmd, nil
}
func (c *ClaudeCodeAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
cmd, err := agent.NewForegroundCommand(ctx, "claude", "-r", sessionID)
if err != nil {
return nil, fmt.Errorf("build claude resume command: %w", err)
}
return cmd, nil
}
Mcmd/entire/cli/agent/claudecode/claude.go+8
239 unmodified lines
240
241
242
243
244
245
246
247
248
249
250
251
252
253
239 unmodified lines
return cmd, nil
}
func (c *CodexAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
cmd, err := agent.NewForegroundCommand(ctx, "codex", "resume", sessionID)
if err != nil {
return nil, fmt.Errorf("build codex resume command: %w", err)
}
return cmd, nil
}
func findRolloutBySessionID(codexHome, agentSessionID string) string {
if codexHome == "" || validation.ValidateAgentSessionID(agentSessionID) != nil {
return ""
Mcmd/entire/cli/agent/codex/codex.go+8
193 unmodified lines
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
193 unmodified lines
t.Errorf("args missing prompt: %v", cmd.Args)
}
}
func TestCodexAgent_LaunchResumeCmd(t *testing.T) {
t.Parallel()
a := NewCodexAgent()
launcher, ok := a.(agent.ResumeLauncher)
if !ok {
t.Fatal("CodexAgent does not implement agent.ResumeLauncher")
}
cmd, err := launcher.LaunchResumeCmd(context.Background(), "019ef36b-a485-7ca2-992b-b4f164266e7f")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
t.Skip("codex binary not on PATH; skipping cmd shape check")
}
t.Fatalf("LaunchResumeCmd: %v", err)
}
if cmd == nil {
t.Fatal("nil cmd")
}
joined := strings.Join(cmd.Args, " ")
if !strings.Contains(joined, "resume 019ef36b-a485-7ca2-992b-b4f164266e7f") {
t.Errorf("args missing resume session: %v", cmd.Args)
}
}
Mcmd/entire/cli/agent/codex/codex_test.go+23
5 unmodified lines
6
7
8
9
10
11
12
133 unmodified lines
146
147
148
149
150
151
152
153
154
155
156
157
158
159
5 unmodified lines
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
133 unmodified lines
return "copilot --resume " + sessionID
}
func (c *CopilotCLIAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
cmd, err := agent.NewForegroundCommand(ctx, "copilot", "--resume", sessionID)
if err != nil {
return nil, fmt.Errorf("build copilot resume command: %w", err)
}
return cmd, nil
}
// ReadTranscript reads the raw JSONL transcript bytes for a session.
func (c *CopilotCLIAgent) ReadTranscript(sessionRef string) ([]byte, error) {
data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input
Mcmd/entire/cli/agent/copilotcli/copilotcli.go+9
5 unmodified lines
6
7
8
9
10
11
12
173 unmodified lines
186
187
188
189
190
191
192
193
194
195
196
5 unmodified lines
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
173 unmodified lines
func (f *FactoryAIDroidAgent) FormatResumeCommand(sessionID string) string {
return "droid --session-id " + sessionID
}
func (f *FactoryAIDroidAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
cmd, err := agent.NewForegroundCommand(ctx, "droid", "--session-id", sessionID)
if err != nil {
return nil, fmt.Errorf("build droid resume command: %w", err)
}
return cmd, nil
}
Mcmd/entire/cli/agent/factoryaidroid/factoryaidroid.go+9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package agent
import (
"context"
"fmt"
"os"
"os/exec"
)
// NewForegroundCommand builds an exec.Cmd wired to the caller's terminal.
// Agent launchers use this for commands the user should interact with directly.
func NewForegroundCommand(ctx context.Context, binary string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath(binary)
if err != nil {
return nil, fmt.Errorf("%s binary not on PATH: %w", binary, err)
}
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
return cmd, nil
}
Acmd/entire/cli/agent/foreground.go+23
395 unmodified lines
396
397
398
399
400
401
402
403
404
405
406
407
408
409
395 unmodified lines
return cmd, nil
}
func (g *GeminiCLIAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
cmd, err := agent.NewForegroundCommand(ctx, "gemini", "--resume", sessionID)
if err != nil {
return nil, fmt.Errorf("build gemini resume command: %w", err)
}
return cmd, nil
}
// ReassembleTranscript merges Gemini JSON chunks by combining their message arrays.
func (g *GeminiCLIAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) {
var allMessages []GeminiMessage
Mcmd/entire/cli/agent/geminicli/gemini.go+8
7 unmodified lines
8
9
10
11
12
13
14
255 unmodified lines
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
7 unmodified lines
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
255 unmodified lines
return "opencode -s " + sessionID
}
func (a *OpenCodeAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
if strings.TrimSpace(sessionID) == "" {
cmd, err := agent.NewForegroundCommand(ctx, "opencode")
if err != nil {
return nil, fmt.Errorf("build opencode resume command: %w", err)
}
return cmd, nil
}
cmd, err := agent.NewForegroundCommand(ctx, "opencode", "-s", sessionID)
if err != nil {
return nil, fmt.Errorf("build opencode resume command: %w", err)
}
return cmd, nil
}
// nonAlphanumericRegex matches any non-alphanumeric character.
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`)
Mcmd/entire/cli/agent/opencode/opencode.go+16
15 unmodified lines
16
17
18
19
20
21
22
261 unmodified lines
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
15 unmodified lines
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
261 unmodified lines
}
return "pi --session " + id
}
func (a *PiAgent) LaunchResumeCmd(ctx context.Context, sessionID string) (*exec.Cmd, error) {
id := strings.TrimSpace(sessionID)
if id == "" {
cmd, err := agent.NewForegroundCommand(ctx, "pi", "--continue")
if err != nil {
return nil, fmt.Errorf("build pi resume command: %w", err)
}
return cmd, nil
}
cmd, err := agent.NewForegroundCommand(ctx, "pi", "--session", id)
if err != nil {
return nil, fmt.Errorf("build pi resume command: %w", err)
}
return cmd, nil
}
Mcmd/entire/cli/agent/pi/pi.go+17
278 unmodified lines
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
278 unmodified lines
return l, ok
}
// ResumeLauncherFor returns the ResumeLauncher implementation for the given
// agent name, or ok=false if the agent cannot be subprocess-launched for resume.
func ResumeLauncherFor(name types.AgentName) (ResumeLauncher, bool) {
a, err := Get(name)
if err != nil {
return nil, false
}
l, ok := a.(ResumeLauncher)
return l, ok
}
// Default returns the default agent.
// Returns nil if the default agent is not registered.
//
Mcmd/entire/cli/agent/registry.go+11
492 unmodified lines
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
3 unmodified lines
528
529
530
531
532
533
534
535
536
537
538
539
492 unmodified lines
}
}
func TestResumeLauncherFor(t *testing.T) {
Register(types.AgentName("resume-launcher-test-agent"), func() Agent {
return &mockResumeLauncherAgent{}
})
t.Cleanup(func() {
registryMu.Lock()
delete(registry, types.AgentName("resume-launcher-test-agent"))
registryMu.Unlock()
})
l, ok := ResumeLauncherFor(types.AgentName("resume-launcher-test-agent"))
if !ok {
t.Fatal("expected resume-launcher-test-agent to implement ResumeLauncher")
}
if l == nil {
t.Fatal("expected non-nil ResumeLauncher")
}
l2, ok2 := ResumeLauncherFor(types.AgentName("does-not-exist"))
if ok2 {
t.Error("expected ok=false for unknown agent")
}
if l2 != nil {
t.Error("expected nil ResumeLauncher for unknown agent")
}
}
// mockLauncherAgent implements Agent and Launcher for testing.
type mockLauncherAgent struct {
mockAgent
3 unmodified lines
func (m *mockLauncherAgent) LaunchCmd(ctx context.Context, _ string) (*exec.Cmd, error) {
return exec.CommandContext(ctx, "true"), nil
}
type mockResumeLauncherAgent struct {
mockAgent
}
//nolint:unparam // error is always nil in this mock; satisfies the ResumeLauncher interface.
func (m *mockResumeLauncherAgent) LaunchResumeCmd(ctx context.Context, _ string) (*exec.Cmd, error) {
return exec.CommandContext(ctx, "true"), nil
}
Mcmd/entire/cli/agent/registry_test.go+35
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
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
//go:build integration
package integration
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/internal/entireclient/contexts"
"github.com/entireio/cli/internal/entireclient/discovery"
"github.com/entireio/cli/internal/entireclient/tokenstore"
)
func TestTrailResume_UsesCheckpointSessionsWhenLocalStateIsMissing(t *testing.T) {
t.Parallel()
env := NewFeatureBranchEnv(t)
addTrailResumeIntegrationOrigin(t, env, "https://github.com/entireio/cli.git")
firstSession := env.NewSession()
firstPrompt := "Create hello method"
if err := env.SimulateUserPromptSubmitWithPrompt(firstSession.ID, firstPrompt); err != nil {
t.Fatalf("SimulateUserPromptSubmit first session: %v", err)
}
firstContent := "def hello; :hello; end\n"
env.WriteFile("hello.rb", firstContent)
firstSession.CreateTranscript(firstPrompt, []FileChange{{Path: "hello.rb", Content: firstContent}})
if err := env.SimulateStop(firstSession.ID, firstSession.TranscriptPath); err != nil {
t.Fatalf("SimulateStop first session: %v", err)
}
secondSession := env.NewSession()
secondPrompt := "Create goodbye method"
if err := env.SimulateUserPromptSubmitWithPrompt(secondSession.ID, secondPrompt); err != nil {
t.Fatalf("SimulateUserPromptSubmit second session: %v", err)
}
secondContent := "def goodbye; :goodbye; end\n"
env.WriteFile("goodbye.rb", secondContent)
secondSession.CreateTranscript(secondPrompt, []FileChange{{Path: "goodbye.rb", Content: secondContent}})
if err := env.SimulateStop(secondSession.ID, secondSession.TranscriptPath); err != nil {
t.Fatalf("SimulateStop second session: %v", err)
}
env.GitCommitWithShadowHooks("Add hello and goodbye methods", "hello.rb", "goodbye.rb")
checkpointID := env.GetLatestCheckpointIDFromHistory()
if err := env.ClearSessionState(firstSession.ID); err != nil {
t.Fatalf("clear first session state: %v", err)
}
if err := env.ClearSessionState(secondSession.ID); err != nil {
t.Fatalf("clear second session state: %v", err)
}
trail := api.TrailResource{
ID: "trail-integration-321",
Number: 321,
URL: "https://entire.io/gh/entireio/cli/trails/321",
Branch: env.GetCurrentBranch(),
Base: masterBranch,
Title: "Resume checkpoint sessions from trail",
Status: "open",
Phase: "building",
CreatedAt: time.Now().Add(-time.Hour).UTC(),
UpdatedAt: time.Now().UTC(),
}
server := newTrailResumeIntegrationAPIServer(t, trail)
defer server.Close()
configureTrailResumeIntegrationAuth(t, env, server.URL)
contextOutput := env.RunCLI("trail", "--insecure-http-auth", "resume", "321", "--no-resume")
for _, want := range []string{
"Trail #321",
"Checkpoint sessions:",
firstSession.ID,
secondSession.ID,
checkpointID,
"Create hello method",
"Create goodbye method",
"entire trail resume 321 --session " + firstSession.ID,
"entire trail resume 321 --session " + secondSession.ID,
} {
if !strings.Contains(contextOutput, want) {
t.Fatalf("trail resume --no-resume output missing %q:\n%s", want, contextOutput)
}
}
if strings.Contains(contextOutput, "none found") {
t.Fatalf("trail resume should not report missing local sessions after reading checkpoint metadata:\n%s", contextOutput)
}
resumeOutput := env.RunCLI("trail", "--insecure-http-auth", "resume", "321", "--session", secondSession.ID)
for _, want := range []string{
"Restored checkpoint session " + secondSession.ID,
"claude -r " + secondSession.ID,
"Create goodbye method",
} {
if !strings.Contains(resumeOutput, want) {
t.Fatalf("trail resume --session output missing %q:\n%s", want, resumeOutput)
}
}
restoredTranscript := filepath.Join(env.ClaudeProjectDir, secondSession.ID+".jsonl")
data, err := os.ReadFile(restoredTranscript)
if err != nil {
t.Fatalf("read restored transcript %s: %v", restoredTranscript, err)
}
if !strings.Contains(string(data), "Create goodbye method") {
t.Fatalf("restored transcript does not contain selected session prompt:\n%s", data)
}
}
func newTrailResumeIntegrationAPIServer(t *testing.T, trail api.TrailResource) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/oauth/token":
writeTrailResumeIntegrationJSON(t, w, http.StatusOK, map[string]any{
"access_token": "trail-resume-data-token",
"token_type": "Bearer",
"expires_in": 3600,
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/trails/gh/entireio/cli":
writeTrailResumeIntegrationJSON(t, w, http.StatusOK, api.TrailListResponse{
Trails: []api.TrailResource{trail},
Total: 1,
Limit: 200,
RepoFullName: "entireio/cli",
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/trails/"+url.PathEscape(trail.ID)+"/reviews/comments":
writeTrailResumeIntegrationJSON(t, w, http.StatusOK, map[string]any{
"comments": []any{},
"has_more": false,
})
default:
http.NotFound(w, r)
}
}))
}
func writeTrailResumeIntegrationJSON(t *testing.T, w http.ResponseWriter, status int, body any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Fatalf("encode JSON response: %v", err)
}
}
func configureTrailResumeIntegrationAuth(t *testing.T, env *TestEnv, coreURL string) {
t.Helper()
configDir := filepath.Join(env.RepoDir, ".entire-test-config")
xdgCacheHome := filepath.Join(env.RepoDir, ".entire-test-cache")
tokenStorePath := filepath.Join(env.RepoDir, ".entire-test-tokens.json")
service := tokenstore.CoreKeyringService(coreURL)
handle := "tester"
if err := contexts.Save(configDir, &contexts.File{
CurrentContext: "tester@trail-resume",
Contexts: []*contexts.Context{
{
Name: "tester@trail-resume",
CoreURL: coreURL,
Handle: handle,
KeychainService: service,
},
},
}); err != nil {
t.Fatalf("save auth context: %v", err)
}
host := mustTrailResumeIntegrationHost(t, coreURL)
cacheDir := filepath.Join(xdgCacheHome, "entire")
if err := discovery.ModifyAPICores(cacheDir, func(c discovery.ClusterCoresCache) error {
c.Set(host, []string{coreURL})
return nil
}); err != nil {
t.Fatalf("seed API discovery cache: %v", err)
}
tokenStore := map[string]map[string]string{
service: {
handle: tokenstore.EncodeTokenWithExpiration(fakeLoginJWT(coreURL), 7200),
},
}
tokenData, err := json.Marshal(tokenStore)
if err != nil {
t.Fatalf("marshal token store: %v", err)
}
if err := os.WriteFile(tokenStorePath, tokenData, 0o600); err != nil {
t.Fatalf("write token store: %v", err)
}
env.ExtraEnv = append(env.ExtraEnv,
"ENTIRE_API_BASE_URL="+coreURL,
"ENTIRE_CONFIG_DIR="+configDir,
"XDG_CACHE_HOME="+xdgCacheHome,
"ENTIRE_TOKEN_STORE=file",
"ENTIRE_TOKEN_STORE_PATH="+tokenStorePath,
)
}
func mustTrailResumeIntegrationHost(t *testing.T, rawURL string) string {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse URL %q: %v", rawURL, err)
}
if parsed.Host == "" {
t.Fatalf("URL %q has no host", rawURL)
}
return parsed.Host
}
func addTrailResumeIntegrationOrigin(t *testing.T, env *TestEnv, remoteURL string) {
t.Helper()
cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", remoteURL)
cmd.Dir = env.RepoDir
cmd.Env = testutil.GitIsolatedEnv()
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git remote add origin: %v\n%s", err, output)
}
configData, err := os.ReadFile(filepath.Join(env.RepoDir, ".git", "config"))
if err != nil {
t.Fatalf("read git config after remote add: %v", err)
}
if !strings.Contains(string(configData), remoteURL) {
t.Fatalf("git config does not contain origin URL %q:\n%s", remoteURL, configData)
}
env.AcceptGitConfigChanges(string(configData))
}
Acmd/entire/cli/integration_test/trail_resume_test.go+244
191 unmodified lines
192
193
194
195
196
197
198
199
200
201
202
203
196
204
205
206
207
208
201
209
210
211
212
213
214
207
215
216
217
218
7 unmodified lines
226
227
228
221
229
230
231
224
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
232
248
249
250
251
252
253
254
239
255
256
257
258
243
259
260
261
262
12 unmodified lines
275
276
277
262
278
279
280
281
266
282
283
284
285
2 unmodified lines
288
289
290
275
291
292
293
294
13 unmodified lines
308
309
310
295
311
312
313
314
15 unmodified lines
330
331
332
317
333
334
335
336
3 unmodified lines
340
341
342
327
343
344
345
346
220 unmodified lines
567
568
569
554
555
570
571
572
5 unmodified lines
578
579
580
567
568
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
570
571
616
617
618
573
619
620
621
576
622
623
624
625
8 unmodified lines
634
635
636
591
637
638
639
640
641
642
643
598
644
645
646
647
648
649
604
650
651
652
653
608
654
655
656
611
657
658
659
660
615
661
662
663
664
211 unmodified lines
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
839
893
894
895
896
7 unmodified lines
904
905
906
853
907
908
909
910
911
858
912
913
914
915
916
863
917
918
919
920
10 unmodified lines
931
932
933
880
934
935
936
937
1 unmodified line
939
940
941
888
942
943
944
945
191 unmodified lines
// does not search branch history — the caller already knows which checkpoint to
// resume, so two sessions on the same branch resume independently.
func resumeByCheckpointID(ctx context.Context, w, errW io.Writer, checkpointID id.CheckpointID, force bool) error {
sessions, err := restoreByCheckpointID(ctx, w, errW, checkpointID, force)
if err != nil || len(sessions) == 0 {
return err
}
return displayRestoredSessions(w, sessions)
}
func restoreByCheckpointID(ctx context.Context, w, errW io.Writer, checkpointID id.CheckpointID, force bool) ([]strategy.RestoredSession, error) {
if checkpointID.IsEmpty() {
return errors.New("no checkpoint to resume")
return nil, errors.New("no checkpoint to resume")
}
repo, err := openRepository(ctx)
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
return nil, fmt.Errorf("not a git repository: %w", err)
}
defer repo.Close()
stores, err := checkpoint.Open(ctx, repo, checkpoint.OpenOptions{BlobFetcher: FetchBlobsByHash})
if err != nil {
return fmt.Errorf("open checkpoint store: %w", err)
return nil, fmt.Errorf("open checkpoint store: %w", err)
}
store := stores.Primary
refs := stores.Refs()
7 unmodified lines
slog.String("checkpoint_id", checkpointID.String()),
slog.String("error", err.Error()),
)
return checkRemoteMetadata(ctx, w, errW, checkpointID, stores.Refs())
return nil, checkRemoteMetadata(ctx, w, errW, checkpointID, stores.Refs())
}
return resumeSession(ctx, w, errW, metadata, force)
return restoreResumeSessions(ctx, w, errW, metadata, force)
}
func resumeFromCurrentBranch(ctx context.Context, w, errW io.Writer, branchName string, force bool) error {
sessions, err := restoreFromCurrentBranch(ctx, w, errW, branchName, force)
if err != nil || len(sessions) == 0 {
return err
}
return displayRestoredSessions(w, sessions)
}
func restoreFromCurrentBranch(ctx context.Context, w, errW io.Writer, branchName string, force bool) ([]strategy.RestoredSession, error) {
logCtx := logging.WithComponent(ctx, "resume")
// Find a commit with an Entire-Checkpoint trailer, looking at branch-only commits
result, err := findBranchCheckpoints(repo, branchName)
if err != nil {
return err
return nil, err
}
if len(result.checkpointIDs) == 0 {
fmt.Fprintf(w, "No Entire checkpoint found on branch '%s'\n", branchName)
return nil
return nil, nil
}
logging.Debug(logCtx, "found checkpoint(s) on branch",
12 unmodified lines
shouldResume, err := promptResumeFromOlderCheckpoint()
if err != nil {
return err
return nil, err
}
if !shouldResume {
fmt.Fprintf(w, "Resume cancelled.\n")
return nil
return nil, nil
}
}
2 unmodified lines
stores, err := checkpoint.Open(ctx, repo, checkpoint.OpenOptions{BlobFetcher: FetchBlobsByHash})
if err != nil {
return fmt.Errorf("open checkpoint store: %w", err)
return nil, fmt.Errorf("open checkpoint store: %w", err)
}
store := stores.Primary
13 unmodified lines
)
fmt.Fprintf(w, "Found %d checkpoints for commit %s but metadata is not available\n",
len(result.checkpointIDs), result.commitHash[:7])
return checkRemoteMetadata(ctx, w, errW, result.checkpointIDs[0], stores.Refs())
return nil, checkRemoteMetadata(ctx, w, errW, result.checkpointIDs[0], stores.Refs())
}
skipped := len(result.checkpointIDs) - 1
fmt.Fprintf(w, "Found %d checkpoints for commit %s, resuming from the latest (%d older checkpoints skipped)\n",
15 unmodified lines
slog.String("checkpoint_id", checkpointID.String()),
slog.String("error", storeErr.Error()),
)
return checkRemoteMetadata(ctx, w, errW, checkpointID, stores.Refs())
return nil, checkRemoteMetadata(ctx, w, errW, checkpointID, stores.Refs())
}
}
3 unmodified lines
slog.Int("session_count", metadata.SessionCount),
)
return resumeSession(ctx, w, errW, metadata, force)
return restoreResumeSessions(ctx, w, errW, metadata, force)
}
// resolveLatestCheckpoint reads metadata for each checkpoint ID and returns
220 unmodified lines
// among commits that are unique to this branch (not reachable from the default branch).
// This handles the case where main has been merged into the feature branch.
func findBranchCheckpoints(repo *git.Repository, branchName string) (*branchCheckpointsResult, error) {
result := &branchCheckpointsResult{}
// Get HEAD commit
head, err := repo.Head()
if err != nil {
5 unmodified lines
return nil, fmt.Errorf("failed to get HEAD commit: %w", err)
}
// First, check if HEAD itself has a checkpoint (most common case)
if cpIDs := trailers.ParseAllCheckpoints(headCommit.Message); len(cpIDs) > 0 {
return findBranchCheckpointsFromCommit(repo, branchName, headCommit), nil
}
func findBranchCheckpointsForBranchRef(repo *git.Repository, branchName string) (*branchCheckpointsResult, error) {
commit, err := branchCommit(repo, branchName)
if err != nil {
return nil, err
}
return findBranchCheckpointsFromCommit(repo, branchName, commit), nil
}
func branchCommit(repo *git.Repository, branchName string) (*object.Commit, error) {
for _, refName := range []plumbing.ReferenceName{
plumbing.NewBranchReferenceName(branchName),
plumbing.NewRemoteReferenceName("origin", branchName),
} {
ref, err := repo.Reference(refName, true)
if err != nil {
continue
}
commit, commitErr := repo.CommitObject(ref.Hash())
if commitErr != nil {
return nil, fmt.Errorf("failed to get branch commit for %s: %w", refName, commitErr)
}
return commit, nil
}
return nil, fmt.Errorf("branch '%s' not found locally or on origin", branchName)
}
func findBranchCheckpointsFromCommit(repo *git.Repository, branchName string, startCommit *object.Commit) *branchCheckpointsResult {
result := &branchCheckpointsResult{}
// First, check if the branch tip itself has a checkpoint (most common case).
if cpIDs := trailers.ParseAllCheckpoints(startCommit.Message); len(cpIDs) > 0 {
result.checkpointIDs = cpIDs
result.commitHash = head.Hash().String()
result.commitMessage = headCommit.Message
result.commitHash = startCommit.Hash.String()
result.commitMessage = startCommit.Message
result.newerCommitsExist = false
return result, nil
return result
}
// HEAD doesn't have a checkpoint - find branch-only commits
// The branch tip doesn't have a checkpoint - find branch-only commits.
// Get the default branch name
defaultBranch := getDefaultBranchFromRemote(repo)
if defaultBranch == "" {
8 unmodified lines
// If we can't find a default branch, or we're on it, just walk all commits
if defaultBranch == "" || defaultBranch == branchName {
return findCheckpointInHistory(headCommit, nil), nil
return findCheckpointInHistory(startCommit, nil)
}
// Get the default branch reference
defaultRef, err := repo.Reference(plumbing.NewBranchReferenceName(defaultBranch), true)
if err != nil {
// Default branch doesn't exist locally, fall back to walking all commits
return findCheckpointInHistory(headCommit, nil), nil //nolint:nilerr // Intentional fallback
return findCheckpointInHistory(startCommit, nil)
}
defaultCommit, err := repo.CommitObject(defaultRef.Hash())
if err != nil {
// Can't get default commit, fall back to walking all commits
return findCheckpointInHistory(headCommit, nil), nil //nolint:nilerr // Intentional fallback
return findCheckpointInHistory(startCommit, nil)
}
// Find merge base
mergeBase, err := headCommit.MergeBase(defaultCommit)
mergeBase, err := startCommit.MergeBase(defaultCommit)
if err != nil || len(mergeBase) == 0 {
// No common ancestor, fall back to walking all commits
return findCheckpointInHistory(headCommit, nil), nil //nolint:nilerr // Intentional fallback
return findCheckpointInHistory(startCommit, nil)
}
// Walk from HEAD to merge base, looking for checkpoint
return findCheckpointInHistory(headCommit, &mergeBase[0].Hash), nil
return findCheckpointInHistory(startCommit, &mergeBase[0].Hash)
}
// findCheckpointInHistory walks commit history from start looking for a checkpoint trailer.
211 unmodified lines
// The caller must provide the already-resolved checkpoint metadata to avoid redundant lookups
// and to support both local and remote metadata trees.
func resumeSession(ctx context.Context, w, errW io.Writer, metadata *strategy.CheckpointInfo, force bool) error {
sessions, err := restoreResumeSessions(ctx, w, errW, metadata, force)
if err != nil || len(sessions) == 0 {
return err
}
return displayRestoredSessions(w, sessions)
}
func restoreResumeSessions(ctx context.Context, w, errW io.Writer, metadata *strategy.CheckpointInfo, force bool) ([]strategy.RestoredSession, error) {
checkpointID := metadata.CheckpointID
sessionID := metadata.SessionID
// Resolve agent from checkpoint metadata (same as rewind)
ag, err := strategy.ResolveAgentForRewind(metadata.Agent)
if err != nil {
return fmt.Errorf("failed to resolve agent: %w", err)
return nil, fmt.Errorf("failed to resolve agent: %w", err)
}
// Initialize logging context with agent
7 unmodified lines
// Get worktree root for session directory lookup
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return fmt.Errorf("failed to get worktree root: %w", err)
return nil, fmt.Errorf("failed to get worktree root: %w", err)
}
sessionDir, err := ag.GetSessionDir(repoRoot)
if err != nil {
return fmt.Errorf("failed to determine session directory: %w", err)
return nil, fmt.Errorf("failed to determine session directory: %w", err)
}
// Create directory if it doesn't exist
if err := os.MkdirAll(sessionDir, 0o700); err != nil {
return fmt.Errorf("failed to create session directory: %w", err)
return nil, fmt.Errorf("failed to create session directory: %w", err)
}
// Get strategy and restore sessions using full checkpoint data
10 unmodified lines
sessions, restoreErr := strat.RestoreLogsOnly(ctx, w, errW, point, force)
if restoreErr != nil || len(sessions) == 0 {
// Fall back to single-session restore (e.g., old checkpoints without agent metadata)
return resumeSingleSession(ctx, w, errW, ag, sessionID, checkpointID, repoRoot, force)
return nil, resumeSingleSession(ctx, w, errW, ag, sessionID, checkpointID, repoRoot, force)
}
logging.Debug(logCtx, "resume session completed",
1 unmodified line
slog.Int("session_count", len(sessions)),
)
return displayRestoredSessions(w, sessions)
return sessions, nil
}
// displayRestoredSessions sorts sessions by CreatedAt and prints resume commands.
Mcmd/entire/cli/resume.go+88/-34
25 unmodified lines
26
27
28
29
29
30
31
32
33
34
35
345 unmodified lines
381
382
383
381
384
385
386
387
25 unmodified lines
)
// resumePickerCancel is the sentinel option value for the picker's Cancel entry.
const resumePickerCancel = "cancel"
const (
resumePickerCancel = "cancel"
unknownAgentLabel = "(unknown agent)"
)
// resumableSession pairs a session with the branch and committed checkpoint we
// resolved for it. A session is only resumable when BOTH are known: the branch
345 unmodified lines
agentLabel := string(s.AgentType)
if agentLabel == "" {
agentLabel = "(unknown agent)"
agentLabel = unknownAgentLabel
}
prompt := strings.TrimSpace(s.LastPrompt)
Mcmd/entire/cli/resume_picker.go+5/-2
732 unmodified lines
733
734
735
736
737
736
737
738
739
4 unmodified lines
744
745
746
748
749
750
751
747
748
749
750
751
752
753
754
755
34 unmodified lines
790
791
792
792
793
794
795
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
732 unmodified lines
}
}
// Get first prompt for display
promptPreview := ExtractFirstPrompt(content.Prompts)
promptPreview := restoredPromptPreview(sessionAgent, content.Prompts, content.Transcript, content.Metadata.ReviewPrompt)
// Local log already present and not forcing: keep it untouched, but still
// report the session so the caller can print its resume command.
4 unmodified lines
fmt.Fprintf(w, "Keeping existing local session log\n")
}
restored = append(restored, RestoredSession{
SessionID: sessionID,
Agent: sessionAgent.Type(),
Prompt: promptPreview,
CreatedAt: content.Metadata.CreatedAt,
SessionID: sessionID,
Agent: sessionAgent.Type(),
Prompt: promptPreview,
CreatedAt: content.Metadata.CreatedAt,
Kind: content.Metadata.Kind,
ReviewPrompt: content.Metadata.ReviewPrompt,
})
continue
}
34 unmodified lines
}
restored = append(restored, RestoredSession{
SessionID: sessionID,
Agent: sessionAgent.Type(),
Prompt: promptPreview,
CreatedAt: content.Metadata.CreatedAt,
SessionID: sessionID,
Agent: sessionAgent.Type(),
Prompt: promptPreview,
CreatedAt: content.Metadata.CreatedAt,
Kind: content.Metadata.Kind,
ReviewPrompt: content.Metadata.ReviewPrompt,
})
}
return restored, nil
}
func restoredPromptPreview(sessionAgent agent.Agent, promptContent string, transcript []byte, reviewPrompt string) string {
if prompt := ExtractFirstPrompt(promptContent); prompt != "" {
return prompt
}
if prompt := strings.TrimSpace(reviewPrompt); prompt != "" {
return prompt
}
extractor, ok := sessionAgent.(agent.PromptExtractor)
if !ok || len(transcript) == 0 {
return ""
}
prompts, err := extractPromptsFromTranscriptBytes(extractor, transcript)
if err != nil || len(prompts) == 0 {
return ""
}
return firstRestoredDisplayPrompt(prompts)
}
func firstRestoredDisplayPrompt(prompts []string) string {
for _, prompt := range prompts {
cleaned := strings.TrimSpace(prompt)
if cleaned == "" || isOnlySeparators(cleaned) || isInjectedInstructionPrompt(cleaned) {
continue
}
return TruncateDescription(cleaned, MaxDescriptionLength)
}
return ""
}
func isInjectedInstructionPrompt(prompt string) bool {
trimmed := strings.TrimSpace(prompt)
return strings.HasPrefix(trimmed, "# AGENTS.md instructions for ") ||
strings.HasPrefix(trimmed, "<environment_context>") ||
(strings.Contains(trimmed, "<INSTRUCTIONS>") && strings.Contains(trimmed, "AGENTS.md instructions"))
}
func extractPromptsFromTranscriptBytes(extractor agent.PromptExtractor, transcript []byte) ([]string, error) {
tmp, err := os.CreateTemp("", "entire-restored-transcript-*.jsonl")
if err != nil {
return nil, fmt.Errorf("create temporary transcript: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(transcript); err != nil {
_ = tmp.Close()
return nil, fmt.Errorf("write temporary transcript: %w", err)
}
if err := tmp.Close(); err != nil {
return nil, fmt.Errorf("close temporary transcript: %w", err)
}
prompts, err := extractor.ExtractPrompts(tmpPath, 0)
if err != nil {
return nil, fmt.Errorf("extract prompts from temporary transcript: %w", err)
}
return prompts, nil
}
// ResolveAgentForRewind resolves the agent from checkpoint metadata.
func ResolveAgentForRewind(agentType types.AgentType) (agent.Agent, error) {
ag, err := agent.GetByAgentType(agentType)
Mcmd/entire/cli/strategy/manual_commit_rewind.go+71/-10
261 unmodified lines
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
363 unmodified lines
653
654
655
634
635
636
656
657
658
659
660
661
662
33 unmodified lines
696
697
698
699
700
701
702
703
704
705
706
261 unmodified lines
require.Equal(t, string(checkpointTranscript), string(got), "force restore must overwrite from the checkpoint")
}
func TestRestoredPromptPreviewFallsBackInOrder(t *testing.T) {
t.Parallel()
ag := &restoreLogsOnlyAgent{
extractedPrompts: []string{
"# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nskip me\n</INSTRUCTIONS>",
"<environment_context>\n <cwd>/repo</cwd>\n</environment_context>",
"prompt from transcript",
},
}
if got := restoredPromptPreview(ag, "prompt sidecar", []byte("transcript"), "review prompt"); got != "prompt sidecar" {
t.Fatalf("sidecar prompt = %q, want prompt sidecar", got)
}
if got := restoredPromptPreview(ag, "", []byte("transcript"), "review prompt"); got != "review prompt" {
t.Fatalf("review prompt = %q, want review prompt", got)
}
if got := restoredPromptPreview(ag, "", []byte("transcript"), ""); got != "prompt from transcript" {
t.Fatalf("transcript prompt = %q, want prompt from transcript", got)
}
}
func TestResolveAgentForRewind(t *testing.T) {
t.Parallel()
363 unmodified lines
}
type restoreLogsOnlyAgent struct {
name types.AgentName
agentType types.AgentType
sessionDir string
name types.AgentName
agentType types.AgentType
sessionDir string
extractedPrompts []string
}
var _ agent.Agent = (*restoreLogsOnlyAgent)(nil)
33 unmodified lines
return "restore-logs " + sessionID
}
//nolint:unparam // error is always nil in this test helper; satisfies PromptExtractor.
func (a *restoreLogsOnlyAgent) ExtractPrompts(string, int) ([]string, error) {
return a.extractedPrompts, nil
}
// fakeExternalAgent is a minimal Agent implementation for testing dynamic registration.
// It simulates an external agent that was discovered and registered at runtime.
type fakeExternalAgent struct {
Mcmd/entire/cli/strategy/rewind_test.go+31/-3
265 unmodified lines
266
267
268
269
270
271
272
269
270
271
272
273
274
275
265 unmodified lines
// Each session may come from a different agent, so callers use this to print
// per-session resume commands without re-reading the metadata tree.
type RestoredSession struct {
SessionID string
Agent types.AgentType
Prompt string
CreatedAt time.Time // From session metadata; used by resume to determine most recent
SessionID string
Agent types.AgentType
Prompt string
CreatedAt time.Time // From session metadata; used by resume to determine most recent
Kind string
ReviewPrompt string
}
Mcmd/entire/cli/strategy/strategy.go+6/-4
62 unmodified lines
63
64
65
66
67
68
69
62 unmodified lines
cmd.AddCommand(newTrailCreateCmd())
cmd.AddCommand(newTrailUpdateCmd())
cmd.AddCommand(newTrailCheckoutCmd())
cmd.AddCommand(newTrailResumeCmd())
cmd.AddCommand(newTrailDeleteCmd())
cmd.AddCommand(newTrailFindingCmd())
cmd.AddCommand(newTrailWatchCmd())
Mcmd/entire/cli/trail_cmd.go+1
package cli
import ( "context" "encoding/json" "errors" "fmt" "io" "sort" "strconv" "strings" "text/tabwriter" "time"
"github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/external" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/interactive" sessionpkg "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/stringutil"
"charm.land/huh/v2" "github.com/spf13/cobra" )
const ( trailResumeNoPrompt = "(no prompt)" )
type trailResumeOptions struct { Selector string SessionID string CheckpointID string Force bool JSON bool NoResume bool }
type trailResumeContext struct {
Trail trailResumeTrailContext json:"trail"
Sessions []trailResumeSessionContext json:"sessions"
Findings trailResumeFindingsContext json:"-"
DefaultResume *trailResumeDefaultContext json:"default_resume,omitempty"
Commands []string json:"commands"
}
type trailResumeTrailContext struct {
ID string json:"id,omitempty"
Number int json:"number,omitempty"
Title string json:"title,omitempty"
Branch string json:"branch"
Base string json:"base,omitempty"
Status string json:"status,omitempty"
Phase string json:"phase,omitempty"
URL string json:"url,omitempty"
}
type trailResumeSessionContext struct {
SessionID string json:"session_id"
Agent string json:"agent,omitempty"
LastPrompt string json:"last_prompt,omitempty"
LastActive time.Time json:"last_active,omitempty"
CheckpointID string json:"checkpoint_id"
}
type trailResumeDefaultContext struct {
Branch string json:"branch"
SessionID string json:"session_id,omitempty"
CheckpointID string json:"checkpoint_id,omitempty"
}
type trailResumeFindingsContext struct {
Counts trailReviewCommentCounts json:"counts"
Top []api.TrailReviewComment json:"top"
HasMore bool json:"has_more,omitempty"
Unavailable string json:"unavailable,omitempty"
}
type trailResumeFindingCounts struct {
Open int json:"open"
OpenHigh int json:"open_high"
OpenMedium int json:"open_medium"
OpenLow int json:"open_low"
Resolved int json:"resolved"
Dismissed int json:"dismissed"
Stale int json:"stale"
}
func newTrailResumeCmd() *cobra.Command { var opts trailResumeOptions
cmd := &cobra.Command{
Use: "resume [
The trail may be given as the first argument or via --trail, as a number, id, or branch. Without one, the trail for the current branch is used.
By default, interactive terminals show the trail context and let you choose between checkpoint sessions on the trail branch when there are multiple. Non-interactive runs show the same context and resume the latest checkpoint on the trail branch. Use --session or --checkpoint to resume an exact session or checkpoint.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { selector, err := parseOptionalTrailSelector(args, opts.Selector) if err != nil { return err } opts.Selector = selector if err := validateTrailResumeOptions(opts); err != nil { return err } external.DiscoverAndRegister(cmd.Context()) return runTrailResume(cmd, opts) }, }
cmd.Flags().StringVar(&opts.Selector, "trail", "", "Trail to resume (number, id, or branch; defaults to the current branch's trail)") cmd.Flags().StringVar(&opts.SessionID, "session", "", "Resume a specific known local session on the trail branch") cmd.Flags().StringVar(&opts.CheckpointID, "checkpoint", "", "Resume a specific checkpoint on the trail branch") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Skip prompts and overwrite existing session logs from checkpoints") cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output trail resume context as JSON") cmd.Flags().BoolVar(&opts.NoResume, "no-resume", false, "Show trail resume context without restoring or resuming a session")
return cmd }
func validateTrailResumeOptions(opts trailResumeOptions) error { if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(opts.CheckpointID) != "" { return errors.New("cannot combine --session and --checkpoint") } if opts.JSON && !opts.NoResume { return errors.New("--json can only be used with --no-resume") } if opts.NoResume && (strings.TrimSpace(opts.SessionID) != "" || strings.TrimSpace(opts.CheckpointID) != "") { return errors.New("cannot combine --no-resume with --session or --checkpoint") } if checkpointID := strings.TrimSpace(opts.CheckpointID); checkpointID != "" { if err := id.Validate(checkpointID); err != nil { return fmt.Errorf("validate --checkpoint: %w", err) } } return nil }
func runTrailResume(cmd *cobra.Command, opts trailResumeOptions) error { return runAuthenticatedDataAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err }
found, err := resolveTrailBySelector(ctx, client, forge, owner, repo, opts.Selector) if err != nil { return err } branch := strings.TrimSpace(found.Branch) if branch == "" { return fmt.Errorf("%s has no branch to resume", describeTrailRef(found)) }
sessions, sessionErr := resolveTrailResumeSessionContexts(ctx, branch) if sessionErr != nil && strings.TrimSpace(opts.SessionID) != "" { return sessionErr }
findings, findingsErr := loadTrailResumeFindingsContext(ctx, client, found.ID) if findingsErr != nil { findings.Unavailable = findingsErr.Error() fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not load trail findings: %v\n", findingsErr) }
resumeCtx := buildTrailResumeContext(*found, sessions, findings) if opts.JSON { return encodeTrailResumeContextJSON(cmd.OutOrStdout(), resumeCtx) }
printTrailResumeContext(cmd.OutOrStdout(), resumeCtx) if opts.NoResume { return nil }
if opts.CheckpointID != "" { return resumeTrailCheckpoint(ctx, cmd, branch, id.CheckpointID(opts.CheckpointID), "", opts.Force) }
if opts.SessionID != "" { sessionCtx, ok := findTrailResumeSession(resumeCtx.Sessions, opts.SessionID) if ok { return resumeTrailCheckpoint(ctx, cmd, branch, id.CheckpointID(sessionCtx.CheckpointID), opts.SessionID, opts.Force) } return resumeTrailLatest(ctx, cmd, branch, opts.Force, opts.SessionID) }
if interactive.CanPromptInteractively() && len(resumeCtx.Sessions) > 1 { return runTrailResumePicker(ctx, cmd, branch, resumeCtx.Sessions, opts.Force) }
return resumeTrailLatest(ctx, cmd, branch, opts.Force, "") }) }
func resumeTrailLatest(ctx context.Context, cmd *cobra.Command, branch string, force bool, preferredSessionID string) error { w := cmd.OutOrStdout() errW := cmd.ErrOrStderr()
if !ensureTrailResumeBranchAvailable(ctx, w, branch) { return nil } proceed, err := switchToBranchForResume(ctx, w, errW, branch, trailResumeSkipBranchPrompts(force)) if err != nil || !proceed { return err } sessions, err := restoreFromCurrentBranch(ctx, w, errW, branch, force) if err != nil { return err } return continueTrailRestoredSessions(ctx, cmd, sessions, preferredSessionID) }
func resumeTrailCheckpoint(ctx context.Context, cmd *cobra.Command, branch string, checkpointID id.CheckpointID, preferredSessionID string, force bool) error { w := cmd.OutOrStdout() errW := cmd.ErrOrStderr()
if !ensureTrailResumeBranchAvailable(ctx, w, branch) { return nil } proceed, err := switchToBranchForResume(ctx, w, errW, branch, trailResumeSkipBranchPrompts(force)) if err != nil || !proceed { return err } sessions, err := restoreByCheckpointID(ctx, w, errW, checkpointID, force) if err != nil { return err } return continueTrailRestoredSessions(ctx, cmd, sessions, preferredSessionID) }
func trailResumeSkipBranchPrompts(force bool) bool { return force || !interactive.CanPromptInteractively() }
func continueTrailRestoredSessions(ctx context.Context, cmd *cobra.Command, sessions []strategy.RestoredSession, preferredSessionID string) error { w := cmd.OutOrStdout() if len(sessions) == 0 { return nil }
if preferredSessionID != "" { session, ok := findTrailRestoredSession(sessions, preferredSessionID) if !ok { return fmt.Errorf("session %q was not found in the restored checkpoint", preferredSessionID) } if !interactive.CanPromptInteractively() { return displayTrailRestoredSessions(w, []strategy.RestoredSession{session}) } printTrailRestoredSessionSummary(w, []strategy.RestoredSession{session}) return launchTrailRestoredSession(ctx, w, session) }
if !interactive.CanPromptInteractively() { return displayTrailRestoredSessions(w, sessions) }
printTrailRestoredSessionSummary(w, sessions) if len(sessions) == 1 { return launchTrailRestoredSession(ctx, w, sessions[0]) }
selected, ok, err := promptTrailRestoredSession(ctx, w, sessions) if err != nil || !ok { return err } return launchTrailRestoredSession(ctx, w, selected) }
func printTrailRestoredSessionSummary(w io.Writer, sessions []strategy.RestoredSession) { if len(sessions) > 1 { fmt.Fprintf(w, "\n✓ Restored %d checkpoint sessions.\n", len(sessions)) } else if len(sessions) == 1 { fmt.Fprintf(w, "✓ Restored checkpoint session %s.\n", sessions[0].SessionID) } if len(sessions) > 0 && trailRestoredSessionsAreAllReviewOrInvestigation(sessions) { fmt.Fprintln(w, " Only review/investigation checkpoint sessions were found; these are transcript logs and may not appear as trail UI sessions.") } }
func displayTrailRestoredSessions(w io.Writer, sessions []strategy.RestoredSession) error { if len(sessions) == 0 { return nil } choices := buildTrailResumeRestoredSessionChoices(sessions) printTrailRestoredSessionSummary(w, sessions) if len(choices) > 1 { fmt.Fprintln(w, "To continue:") } else { fmt.Fprintln(w, "\nTo continue this checkpoint session:") }
isMulti := len(choices) > 1 mostRecentSessionID := mostRecentRestoredSessionID(sessions) for _, choice := range choices { sessionAgent, err := strategy.ResolveAgentForRewind(choice.Session.Agent) if err != nil { return fmt.Errorf("failed to resolve agent for session %s: %w", choice.SessionID, err) } printSessionCommand(w, sessionAgent.FormatResumeCommand(choice.SessionID), trailRestoredSessionPrompt(choice.Session), isMulti, choice.SessionID == mostRecentSessionID) } return nil }
func mostRecentRestoredSessionID(sessions []strategy.RestoredSession) string { var latestID string var latestTime time.Time for _, session := range sessions { if session.SessionID == "" || session.CreatedAt.IsZero() { continue } if latestID == "" || session.CreatedAt.After(latestTime) { latestID = session.SessionID latestTime = session.CreatedAt } } return latestID }
func resolveTrailResumeSessionContexts(ctx context.Context, branch string) ([]trailResumeSessionContext, error) { sessions, err := resolveTrailCheckpointSessions(ctx, branch) if err == nil && len(sessions) > 0 { return sessions, nil }
items, localErr := resolveTrailResumeSessions(ctx, branch) if localErr != nil { if err != nil { return nil, err } return nil, localErr } localSessions := trailResumeSessionContextsFromLocal(branch, items) if len(localSessions) > 0 { return localSessions, nil } return sessions, err }
func resolveTrailCheckpointSessions(ctx context.Context, branch string) ([]trailResumeSessionContext, error) { repo, err := openRepository(ctx) if err != nil { return nil, fmt.Errorf("not a git repository: %w", err) } defer repo.Close()
result, err := findBranchCheckpointsForBranchRef(repo, branch) if err != nil { return nil, err } if len(result.checkpointIDs) == 0 { return nil, nil }
stores, err := checkpoint.Open(ctx, repo, checkpoint.OpenOptions{BlobFetcher: FetchBlobsByHash}) if err != nil { return nil, fmt.Errorf("open checkpoint store: %w", err) } store := stores.Primary refs := stores.Refs() if refs.ReadBootstrappableFromOrigin() { promoteRemoteTrackingPrimary(ctx, repo, refs) }
checkpointID := result.checkpointIDs[0] if len(result.checkpointIDs) > 1 { latestMetadata, latestErr := resolveLatestCheckpoint(ctx, store, result.checkpointIDs) if latestErr != nil { return nil, fmt.Errorf("resolve latest checkpoint: %w", latestErr) } checkpointID = latestMetadata.CheckpointID }
return readTrailCheckpointSessionContexts(ctx, store, checkpointID) }
func readTrailCheckpointSessionContexts(ctx context.Context, store checkpointInfoReader, checkpointID id.CheckpointID) ([]trailResumeSessionContext, error) { summary, err := checkpoint.ReadCommittedCheckpoint(ctx, store, checkpointID) if err != nil { return nil, fmt.Errorf("read checkpoint %s: %w", checkpointID, err) }
sessions := make([]trailResumeSessionContext, 0, len(summary.Sessions)) for i := range summary.Sessions { content, contentErr := readTrailCheckpointSessionContent(ctx, store, checkpointID, i) if contentErr != nil { continue } metadata := content.Metadata if strings.TrimSpace(metadata.SessionID) == "" { continue } prompt := strategy.ExtractFirstPrompt(content.Prompts) if prompt == "" { prompt = strings.TrimSpace(metadata.ReviewPrompt) } sessions = append(sessions, trailResumeSessionContext{ SessionID: metadata.SessionID, Agent: string(metadata.Agent), LastPrompt: prompt, LastActive: metadata.CreatedAt, CheckpointID: checkpointID.String(), }) }
if len(sessions) == 0 { info, infoErr := readCheckpointInfoFromStore(ctx, store, checkpointID) if infoErr == nil && strings.TrimSpace(info.SessionID) != "" { sessions = append(sessions, trailResumeSessionContext{ SessionID: info.SessionID, Agent: string(info.Agent), LastActive: info.CreatedAt, CheckpointID: checkpointID.String(), }) } }
sort.SliceStable(sessions, func(i, j int) bool { return sessions[i].LastActive.After(sessions[j].LastActive) }) return sessions, nil }
func readTrailCheckpointSessionContent( ctx context.Context, store checkpointInfoReader, checkpointID id.CheckpointID, sessionIndex int, ) (*checkpoint.SessionContent, error) { if reader, ok := store.(interface { ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*checkpoint.SessionContent, error) }); ok { return reader.ReadSessionMetadataAndPrompts(ctx, checkpointID, sessionIndex) //nolint:wrapcheck // Contextualized by caller. } metadata, err := store.ReadSessionMetadata(ctx, checkpointID, sessionIndex) if err != nil { return nil, err //nolint:wrapcheck // Contextualized by caller. } return &checkpoint.SessionContent{Metadata: *metadata}, nil }
func resolveTrailResumeSessions(ctx context.Context, branch string) ([]resumableSession, error) { states, err := strategy.ListSessionStates(ctx) if err != nil { return nil, fmt.Errorf("failed to list sessions: %w", err) } states = filterResumableSessions(states)
repo, err := openRepository(ctx) if err != nil { items := make([]resumableSession, 0, len(states)) for _, state := range states { if state == nil { continue } items = append(items, resumableSession{ state: state, branch: state.Branch, checkpointID: state.LastCheckpointID, }) } return items, nil } defer repo.Close()
items := resolveResumableBranches(repo, states) for i := range items { if items[i].branch == "" && items[i].state != nil && items[i].state.Branch == branch { items[i].branch = branch } } return items, nil }
func trailResumeSessionContextsFromLocal(branch string, items []resumableSession) []trailResumeSessionContext { var sessions []trailResumeSessionContext for _, item := range items { if item.state == nil || !item.isResumable() || item.branch != branch { continue } sessions = append(sessions, trailResumeSessionContext{ SessionID: item.state.SessionID, Agent: string(item.state.AgentType), LastPrompt: strings.TrimSpace(item.state.LastPrompt), LastActive: sessionLastActiveTime(item.state), CheckpointID: item.checkpointID.String(), }) } sort.SliceStable(sessions, func(i, j int) bool { return sessions[i].LastActive.After(sessions[j].LastActive) }) return sessions }
func loadTrailResumeFindingsContext(ctx context.Context, client *api.Client, trailID string) (trailResumeFindingsContext, error) { if strings.TrimSpace(trailID) == "" { return trailResumeFindingsContext{}, nil }
summaryComments, err := fetchAllTrailReviewComments(ctx, client, trailID, trailReviewSummaryOptions()) if err != nil { return trailResumeFindingsContext{}, err } top, hasMore, err := fetchTrailReviewComments(ctx, client, trailID, trailResumeTopFindingOptions()) if err != nil { return trailResumeFindingsContext{}, err } return trailResumeFindingsContext{ Counts: countTrailReviewComments(summaryComments), Top: top, HasMore: hasMore, }, nil }
func trailResumeTopFindingOptions() trailReviewListOptions { return trailReviewListOptions{ Status: trailReviewStatusOpen, Severity: strings.Join([]string{trailReviewSeverityHigh, trailReviewSeverityMedium}, ","), Freshness: trailReviewFreshnessCurrent, Limit: 3, } }
func buildTrailResumeContext(found api.TrailResource, sessions []trailResumeSessionContext, findings trailResumeFindingsContext) trailResumeContext { trailCtx := trailResumeTrailContext{ ID: found.ID, Number: found.Number, Title: strings.TrimSpace(found.Title), Branch: strings.TrimSpace(found.Branch), Base: strings.TrimSpace(found.Base), Status: strings.TrimSpace(found.Status), Phase: strings.TrimSpace(found.Phase), URL: strings.TrimSpace(found.URL), }
sort.SliceStable(sessions, func(i, j int) bool { return sessions[i].LastActive.After(sessions[j].LastActive) })
var defaultResume *trailResumeDefaultContext if len(sessions) > 0 { defaultResume = &trailResumeDefaultContext{ Branch: trailCtx.Branch, SessionID: sessions[0].SessionID, CheckpointID: sessions[0].CheckpointID, } } else { defaultResume = &trailResumeDefaultContext{Branch: trailCtx.Branch} }
ctx := trailResumeContext{ Trail: trailCtx, Sessions: sessions, Findings: findings, DefaultResume: defaultResume, } ctx.Commands = buildTrailResumeCommands(ctx) return ctx }
func buildTrailResumeCommands(ctx trailResumeContext) []string { selector := trailResumeSelectorForCommands(ctx.Trail) if selector == "" { return nil } arg := shellArg(selector) commands := []string{ "entire trail finding " + arg + " --json", "entire trail resume " + arg, } if ctx.DefaultResume != nil && ctx.DefaultResume.CheckpointID != "" { commands = append(commands, "entire trail resume "+arg+" --checkpoint "+shellArg(ctx.DefaultResume.CheckpointID)) } for _, session := range ctx.Sessions { commands = append(commands, "entire trail resume "+arg+" --session "+shellArg(session.SessionID)) } return commands }
func trailResumeSelectorForCommands(trail trailResumeTrailContext) string { if trail.Number > 0 { return strconv.Itoa(trail.Number) } if trail.ID != "" { return trail.ID } return trail.Branch }
func shellArg(s string) string { if s == "" { return "''" } for , r := range s { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '' || r == '.' || r == '/' || r == ':' { continue } return shellQuote(s) } return s }
func printTrailResumeContext(w io.Writer, ctx trailResumeContext) { printTrailResumeTrail(w, ctx.Trail) printTrailResumeSessions(w, ctx.Sessions) printTrailResumeFindings(w, ctx.Findings) printTrailResumeCommands(w, ctx.Commands) fmt.Fprintln(w) }
func printTrailResumeTrail(w io.Writer, trail trailResumeTrailContext) { switch { case trail.Number > 0: fmt.Fprintf(w, " Trail #%d %s\n", trail.Number, trail.Title) case trail.ID != "": fmt.Fprintf(w, " Trail %s %s\n", trail.ID, trail.Title) default: fmt.Fprintf(w, " Trail %s\n", trail.Title) } parts := []string{} if trail.Status != "" { parts = append(parts, "Status: "+trail.Status) } if trail.Phase != "" { parts = append(parts, "Phase: "+trail.Phase) } if trail.Branch != "" { parts = append(parts, "Branch: "+trail.Branch) } if len(parts) > 0 { fmt.Fprintf(w, " %s\n", strings.Join(parts, " · ")) } if trail.Base != "" { fmt.Fprintf(w, " Base: %s\n", trail.Base) } if trail.URL != "" { fmt.Fprintf(w, " URL: %s\n", trail.URL) } fmt.Fprintln(w) }
func printTrailResumeSessions(w io.Writer, sessions []trailResumeSessionContext) { fmt.Fprintln(w, " Checkpoint sessions:") if len(sessions) == 0 { fmt.Fprintln(w, " none found before restore") fmt.Fprintln(w) return } var table strings.Builder tw := tabwriter.NewWriter(&table, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "SESSION\tAGENT\tCHECKPOINT\tLAST ACTIVE\tPROMPT") for _, session := range sessions { prompt := strings.TrimSpace(session.LastPrompt) if prompt == "" { prompt = trailResumeNoPrompt } else { prompt = stringutil.TruncateRunes(stringutil.CollapseWhitespace(prompt), 72, "...") } agent := session.Agent if agent == "" { agent = unknownPlaceholder } when := "-" if !session.LastActive.IsZero() { when = timeAgo(session.LastActive) } fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", abbreviate12(session.SessionID), agent, session.CheckpointID, when, prompt, ) } _ = tw.Flush() printIndentedBlock(w, table.String(), " ") fmt.Fprintln(w) }
func printTrailResumeFindings(w io.Writer, findings trailResumeFindingsContext) { counts := findings.Counts fmt.Fprintf(w, " Findings: open %d high %d medium %d low %d resolved %d dismissed %d stale %d\n", counts.Open, counts.OpenHigh, counts.OpenMedium, counts.OpenLow, counts.Resolved, counts.Dismissed, counts.Stale) if findings.Unavailable != "" { fmt.Fprintf(w, " unavailable: %s\n\n", findings.Unavailable) return } if len(findings.Top) == 0 { fmt.Fprintln(w, " no current high/medium open findings") fmt.Fprintln(w) return } var table strings.Builder tw := tabwriter.NewWriter(&table, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "ID\tSEV\tLOCATION\tSUMMARY") for _, finding := range findings.Top { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", abbreviate12(finding.ID), severityTableDisplay(finding.Severity), trailReviewLocationDisplay(finding.Location), trailReviewCommentSummary(finding), ) } _ = tw.Flush() printIndentedBlock(w, table.String(), " ") if findings.HasMore { fmt.Fprintln(w, " more high/medium findings available; run the finding command for the full list") } fmt.Fprintln(w) }
func printTrailResumeCommands(w io.Writer, commands []string) { if len(commands) == 0 { return } fmt.Fprintln(w, " Commands:") for _, command := range commands { fmt.Fprintf(w, " %s\n", command) } }
func encodeTrailResumeContextJSON(w io.Writer, ctx trailResumeContext) error {
payload := struct {
Trail trailResumeTrailContext json:"trail"
Sessions []trailResumeSessionContext json:"sessions"
FindingsSummary trailResumeFindingCounts json:"findings_summary"
Findings []api.TrailReviewComment json:"findings"
FindingsHasMore bool json:"findings_has_more,omitempty"
FindingsUnavailable string json:"findings_unavailable,omitempty"
DefaultResume *trailResumeDefaultContext json:"default_resume,omitempty"
Commands []string json:"commands"
}{
Trail: ctx.Trail,
Sessions: ctx.Sessions,
FindingsSummary: trailResumeFindingCountsFromReviewCounts(ctx.Findings.Counts),
Findings: ctx.Findings.Top,
FindingsHasMore: ctx.Findings.HasMore,
FindingsUnavailable: ctx.Findings.Unavailable,
DefaultResume: ctx.DefaultResume,
Commands: ctx.Commands,
}
enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(payload); err != nil { return fmt.Errorf("encode trail resume JSON: %w", err) } return nil }
func trailResumeFindingCountsFromReviewCounts(counts trailReviewCommentCounts) trailResumeFindingCounts { return trailResumeFindingCounts(counts) }
func findTrailResumeSession(sessions []trailResumeSessionContext, sessionID string) (trailResumeSessionContext, bool) { for _, session := range sessions { if session.SessionID == sessionID { return session, true } } return trailResumeSessionContext{}, false }
type trailResumeRestoredSessionChoice struct { SessionID string Label string Session strategy.RestoredSession }
func buildTrailResumeRestoredSessionChoices(sessions []strategy.RestoredSession) []trailResumeRestoredSessionChoice { sorted := append([]strategy.RestoredSession(nil), sessions...) sort.SliceStable(sorted, func(i, j int) bool { if trailRestoredSessionSortRank(sorted[i]) != trailRestoredSessionSortRank(sorted[j]) { return trailRestoredSessionSortRank(sorted[i]) < trailRestoredSessionSortRank(sorted[j]) } return sorted[i].CreatedAt.After(sorted[j].CreatedAt) })
choices := make([]trailResumeRestoredSessionChoice, 0, len(sorted)) for i, session := range sorted { choices = append(choices, trailResumeRestoredSessionChoice{ SessionID: session.SessionID, Label: trailRestoredSessionChoiceLabel(session, i == 0 && len(sorted) > 1), Session: session, }) } return choices }
func trailRestoredSessionSortRank(session strategy.RestoredSession) int { switch sessionpkg.Kind(session.Kind) { case sessionpkg.KindAgentReview, sessionpkg.KindAgentInvestigate: return 1 default: if trailRestoredSessionLooksReviewLike(session) { return 1 } return 0 } }
func trailRestoredSessionChoiceLabel(session strategy.RestoredSession, isDefault bool) string { prompt := trailRestoredSessionPrompt(session) if prompt == "" { prompt = trailResumeNoPrompt } else { prompt = stringutil.TruncateRunes(stringutil.CollapseWhitespace(prompt), 50, "...") } agentName := strings.TrimSpace(string(session.Agent)) if agentName == "" { agentName = unknownAgentLabel } when := "-" if !session.CreatedAt.IsZero() { when = timeAgo(session.CreatedAt) } parts := []string{session.SessionID, prompt, agentName, "last active " + when} if kindLabel := trailRestoredSessionKindLabel(session.Kind); kindLabel != "" { parts = append(parts, kindLabel) } else if trailRestoredSessionLooksReviewLike(session) { parts = append(parts, "review") } if isDefault { parts = append(parts, "default") } return strings.Join(parts, " · ") }
func trailRestoredSessionKindLabel(kind string) string { switch sessionpkg.Kind(kind) { case sessionpkg.KindAgentReview: return "review" case sessionpkg.KindAgentInvestigate: return "investigation" default: return "" } }
func trailRestoredSessionPrompt(session strategy.RestoredSession) string { if prompt := strings.TrimSpace(session.Prompt); prompt != "" { return prompt } return strings.TrimSpace(session.ReviewPrompt) }
func trailRestoredSessionLooksReviewLike(session strategy.RestoredSession) bool { prompt := strings.ToLower(trailRestoredSessionPrompt(session)) return strings.HasPrefix(prompt, "review the code changes") || strings.HasPrefix(prompt, "review this branch") || strings.HasPrefix(prompt, "review the branch") }
func trailRestoredSessionsAreAllReviewOrInvestigation(sessions []strategy.RestoredSession) bool { for _, session := range sessions { if trailRestoredSessionSortRank(session) == 0 { return false } } return len(sessions) > 0 }
func promptTrailRestoredSession(ctx context.Context, w io.Writer, sessions []strategy.RestoredSession) (strategy.RestoredSession, bool, error) { choices := buildTrailResumeRestoredSessionChoices(sessions) if len(choices) == 0 { return strategy.RestoredSession{}, false, nil }
options := make([]huh.Option[string], 0, len(choices)+1) for _, choice := range choices { options = append(options, huh.NewOption(choice.Label, choice.SessionID)) } options = append(options, huh.NewOption("Cancel", resumePickerCancel))
selected := choices[0].SessionID form := NewAccessibleForm( huh.NewGroup( huh.NewSelectstring. Title("Choose a checkpoint session to resume"). Description("These are agent transcript logs restored from the branch checkpoint; they may not appear as trail UI sessions."). Options(options...). Value(&selected), ), ) if err := form.RunWithContext(ctx); err != nil { if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, context.Canceled) { return strategy.RestoredSession{}, false, nil } return strategy.RestoredSession{}, false, fmt.Errorf("selection failed: %w", err) } if selected == "" || selected == resumePickerCancel { fmt.Fprintln(w, "Resume cancelled.") return strategy.RestoredSession{}, false, nil } for _, choice := range choices { if choice.SessionID == selected { return choice.Session, true, nil } } return strategy.RestoredSession{}, false, fmt.Errorf("invalid selection %q", selected) }
func findTrailRestoredSession(sessions []strategy.RestoredSession, sessionID string) (strategy.RestoredSession, bool) { for _, session := range sessions { if session.SessionID == sessionID { return session, true } } return strategy.RestoredSession{}, false }
func launchTrailRestoredSession(ctx context.Context, w io.Writer, session strategy.RestoredSession) error { resumeAgent, err := strategy.ResolveAgentForRewind(session.Agent) if err != nil { return fmt.Errorf("failed to resolve agent for session %s: %w", session.SessionID, err) } resumeCmd := resumeAgent.FormatResumeCommand(session.SessionID) launcher, ok := agent.ResumeLauncherFor(resumeAgent.Name()) if !ok { fmt.Fprintf(w, "\nTo continue this session:\n") printSessionCommand(w, resumeCmd, session.Prompt, false, true) return nil } cmd, err := launcher.LaunchResumeCmd(ctx, session.SessionID) if err != nil { fmt.Fprintf(w, "\nCould not launch %s: %v\n", resumeCmd, err) fmt.Fprintf(w, "\nTo continue this session:\n") printSessionCommand(w, resumeCmd, session.Prompt, false, true) return nil } fmt.Fprintf(w, "\nLaunching: %s\n", resumeCmd) if err := cmd.Run(); err != nil { return fmt.Errorf("resume command failed: %w", err) } return nil }
func runTrailResumePicker(ctx context.Context, cmd *cobra.Command, branch string, sessions []trailResumeSessionContext, force bool) error { if !ensureTrailResumeBranchAvailable(ctx, cmd.OutOrStdout(), branch) { return nil }
options := make([]huh.Option[string], 0, len(sessions)+1) for _, session := range sessions { options = append(options, huh.NewOption(trailResumeSessionOptionLabel(session), session.SessionID)) } options = append(options, huh.NewOption("Cancel", resumePickerCancel))
selected := sessions[0].SessionID form := NewAccessibleForm( huh.NewGroup( huh.NewSelectstring. Title("Choose a checkpoint session to resume"). Description("These sessions are recorded in the trail branch checkpoint."). Options(options...). Value(&selected), ), ) if err := form.RunWithContext(ctx); err != nil { if errors.Is(err, huh.ErrUserAborted) || errors.Is(err, context.Canceled) { return nil } return fmt.Errorf("selection failed: %w", err) } if selected == "" || selected == resumePickerCancel { fmt.Fprintln(cmd.OutOrStdout(), "Resume cancelled.") return nil } sessionCtx, ok := findTrailResumeSession(sessions, selected) if !ok { return fmt.Errorf("invalid selection %q", selected) } return resumeTrailCheckpoint(ctx, cmd, branch, id.CheckpointID(sessionCtx.CheckpointID), sessionCtx.SessionID, force) }
func ensureTrailResumeBranchAvailable(ctx context.Context, w io.Writer, branch string) bool { otherPath, ok := branchCheckedOutElsewhere(ctx, branch) if !ok { return true } fmt.Fprint(w, trailResumeWorktreeClashMessage(branch, otherPath)) return false }
func trailResumeWorktreeClashMessage(branch, otherPath string) string { var b strings.Builder fmt.Fprintf(&b, "Branch %q is already checked out in another worktree:\n", branch) fmt.Fprintf(&b, " %s\n\n", otherPath) fmt.Fprintf(&b, "Resume from that worktree with:\n") fmt.Fprintf(&b, " cd %s && entire trail resume %s\n", shellQuote(otherPath), shellArg(branch)) return b.String() }
func trailResumeSessionOptionLabel(session trailResumeSessionContext) string { prompt := strings.TrimSpace(session.LastPrompt) if prompt == "" { prompt = trailResumeNoPrompt } else { prompt = stringutil.TruncateRunes(stringutil.CollapseWhitespace(prompt), 50, "...") } agent := session.Agent if agent == "" { agent = unknownAgentLabel } when := "-" if !session.LastActive.IsZero() { when = timeAgo(session.LastActive) } return fmt.Sprintf("%s · %s · %s · last active %s", session.SessionID, prompt, agent, when) }
Acmd/entire/cli/trail\_resume\_cmd.go+1023
package cli
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/redact"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/object"
)
func TestTrailResumeCmdRejectsConflictingSelectors(t *testing.T) {
t.Parallel()
cmd := newTrailResumeCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"575", "--trail", "feature/a"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error combining positional trail with --trail, got nil")
}
if !strings.Contains(err.Error(), "not both") {
t.Fatalf("error = %q, want it to mention 'not both'", err)
}
}
func TestValidateTrailResumeOptions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opts trailResumeOptions
wantErr string
}{
{
name: "session and checkpoint conflict",
opts: trailResumeOptions{SessionID: "session-1", CheckpointID: "0123456789ab"},
wantErr: "cannot combine --session and --checkpoint",
},
{
name: "json requires no resume",
opts: trailResumeOptions{JSON: true},
wantErr: "--json can only be used with --no-resume",
},
{
name: "json no resume accepted",
opts: trailResumeOptions{JSON: true, NoResume: true},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateTrailResumeOptions(tc.opts)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("validateTrailResumeOptions() = %v, want nil", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("validateTrailResumeOptions() = %v, want %q", err, tc.wantErr)
}
})
}
}
func TestBuildTrailResumeContextSortsCheckpointSessions(t *testing.T) {
t.Parallel()
now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC)
ctx := buildTrailResumeContext(api.TrailResource{
ID: "trl_1",
Number: 575,
Title: "Add trail resume",
Branch: "feature/trail-resume",
Status: "open",
Phase: "has_code",
}, []trailResumeSessionContext{
{
SessionID: "old-session",
Agent: "claude-code",
LastPrompt: "older work",
LastActive: now.Add(-time.Hour),
CheckpointID: "bbbbbbbbbbbb",
},
{
SessionID: "new-session",
Agent: "codex",
LastPrompt: "newer work",
LastActive: now,
CheckpointID: "aaaaaaaaaaaa",
},
}, trailResumeFindingsContext{})
if len(ctx.Sessions) != 2 {
t.Fatalf("sessions len = %d, want 2: %#v", len(ctx.Sessions), ctx.Sessions)
}
if ctx.Sessions[0].SessionID != "new-session" || ctx.Sessions[0].CheckpointID != "aaaaaaaaaaaa" {
t.Fatalf("first session = %#v, want newest trail session", ctx.Sessions[0])
}
if ctx.Sessions[1].SessionID != "old-session" {
t.Fatalf("second session = %#v, want old-session", ctx.Sessions[1])
}
if ctx.DefaultResume == nil || ctx.DefaultResume.SessionID != "new-session" {
t.Fatalf("DefaultResume = %#v, want new-session", ctx.DefaultResume)
}
}
func TestResolveTrailCheckpointSessionsUsesBranchCheckpointMetadata(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
testutil.InitRepo(t, tmpDir)
repo, err := git.PlainOpen(tmpDir)
if err != nil {
t.Fatalf("open repo: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("worktree: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "readme.md"), []byte("init\n"), 0o644); err != nil {
t.Fatalf("write readme: %v", err)
}
if _, err := wt.Add("readme.md"); err != nil {
t.Fatalf("add readme: %v", err)
}
if _, err := wt.Commit("init", &git.CommitOptions{Author: testTrailResumeSignature(time.Date(2026, 6, 23, 9, 0, 0, 0, time.UTC))}); err != nil {
t.Fatalf("commit init: %v", err)
}
if err := wt.Checkout(&git.CheckoutOptions{Create: true, Branch: "refs/heads/feature/trail"}); err != nil {
t.Fatalf("checkout feature: %v", err)
}
cpID := id.MustCheckpointID("abc123def456")
firstTime := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
secondTime := firstTime.Add(time.Hour)
writeTrailResumeCheckpointSession(t, repo, cpID, "session-alice", firstTime, agent.AgentTypeClaudeCode, "alice started this trail")
writeTrailResumeCheckpointSession(t, repo, cpID, "session-bob", secondTime, agent.AgentTypeCodex, "bob continued from another machine")
if err := os.WriteFile(filepath.Join(tmpDir, "readme.md"), []byte("feature\n"), 0o644); err != nil {
t.Fatalf("write feature: %v", err)
}
if _, err := wt.Add("readme.md"); err != nil {
t.Fatalf("add feature: %v", err)
}
if _, err := wt.Commit("feature work\n\nEntire-Checkpoint: "+cpID.String(), &git.CommitOptions{Author: testTrailResumeSignature(secondTime)}); err != nil {
t.Fatalf("commit feature: %v", err)
}
sessions, err := resolveTrailCheckpointSessions(context.Background(), "feature/trail")
if err != nil {
t.Fatalf("resolveTrailCheckpointSessions() error = %v", err)
}
if len(sessions) != 2 {
t.Fatalf("sessions len = %d, want 2: %#v", len(sessions), sessions)
}
if sessions[0].SessionID != "session-bob" || sessions[0].CheckpointID != cpID.String() {
t.Fatalf("first session = %#v, want newest checkpoint session", sessions[0])
}
if sessions[0].Agent != string(agent.AgentTypeCodex) {
t.Fatalf("first agent = %q, want %q", sessions[0].Agent, agent.AgentTypeCodex)
}
if sessions[0].LastPrompt != "bob continued from another machine" {
t.Fatalf("first prompt = %q", sessions[0].LastPrompt)
}
if sessions[1].SessionID != "session-alice" {
t.Fatalf("second session = %#v, want session-alice", sessions[1])
}
}
func testTrailResumeSignature(when time.Time) *object.Signature {
return &object.Signature{
Name: "Test User",
Email: "test@example.com",
When: when,
}
}
func writeTrailResumeCheckpointSession(
t *testing.T,
repo *git.Repository,
checkpointID id.CheckpointID,
sessionID string,
createdAt time.Time,
agentType types.AgentType,
prompt string,
) {
t.Helper()
if err := checkpoint.NewGitStore(repo, checkpoint.DefaultV1Refs()).WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: sessionID,
CreatedAt: createdAt,
Strategy: resumeTestStrategy,
Branch: "feature/trail",
Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"` + prompt + `"}]}}` + "\n")),
Prompts: []string{prompt},
Agent: agentType,
AuthorName: "Test",
AuthorEmail: "test@example.com",
}); err != nil {
t.Fatalf("WriteCommitted(%s): %v", sessionID, err)
}
}
func TestPrintTrailResumeContextIncludesSessionsFindingsAndCommands(t *testing.T) {
t.Parallel()
sev := trailReviewSeverityHigh
line := 42
file := "cmd/entire/cli/trail_cmd.go"
ctx := trailResumeContext{
Trail: trailResumeTrailContext{
ID: "trl_1",
Number: 575,
Title: "Add trail resume",
Branch: "feature/trail-resume",
Status: "open",
Phase: "has_code",
URL: "https://entire.io/gh/o/r/trails/575",
},
Sessions: []trailResumeSessionContext{{
SessionID: "session-1",
Agent: "codex",
LastPrompt: "implement trail resume",
LastActive: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
CheckpointID: "aaaaaaaaaaaa",
}},
Findings: trailResumeFindingsContext{
Counts: trailReviewCommentCounts{Open: 1, OpenHigh: 1, Resolved: 2},
Top: []api.TrailReviewComment{{
ID: "finding-1",
Body: trailReviewStrPtr("Resume output should show context"),
Severity: &sev,
Status: trailReviewStatusOpen,
Location: api.TrailReviewLocation{
Granularity: "line",
FilePath: &file,
StartLine: &line,
},
}},
},
Commands: []string{
"entire trail finding 575 --json",
"entire trail resume 575 --session session-1",
},
}
var out strings.Builder
printTrailResumeContext(&out, ctx)
text := out.String()
for _, want := range []string{
"Trail #575 Add trail resume",
"Status: open · Phase: has_code · Branch: feature/trail-resume",
"Checkpoint sessions:",
"session-1",
"codex",
"aaaaaaaaaaaa",
"Findings: open 1",
"high 1",
"finding-1",
"cmd/entire/cli/trail_cmd.go:42",
"Resume output should show context",
"Commands:",
"entire trail finding 575 --json",
"entire trail resume 575 --session session-1",
} {
if !strings.Contains(text, want) {
t.Fatalf("context output missing %q:\n%s", want, text)
}
}
}
func TestEncodeTrailResumeContextJSON(t *testing.T) {
t.Parallel()
sev := trailReviewSeverityHigh
ctx := trailResumeContext{
Trail: trailResumeTrailContext{ID: "trl_1", Number: 575, Branch: "feature/trail-resume"},
Sessions: []trailResumeSessionContext{{
SessionID: "session-1",
CheckpointID: "aaaaaaaaaaaa",
}},
Findings: trailResumeFindingsContext{
Counts: trailReviewCommentCounts{Open: 1, OpenHigh: 1},
Top: []api.TrailReviewComment{{
ID: "finding-1",
Severity: &sev,
Status: trailReviewStatusOpen,
}},
},
DefaultResume: &trailResumeDefaultContext{SessionID: "session-1", CheckpointID: "aaaaaaaaaaaa", Branch: "feature/trail-resume"},
Commands: []string{"entire trail resume 575 --session session-1"},
}
var out bytes.Buffer
if err := encodeTrailResumeContextJSON(&out, ctx); err != nil {
t.Fatalf("encodeTrailResumeContextJSON: %v", err)
}
var decoded struct {
Trail struct {
ID string `json:"id"`
Number int `json:"number"`
Branch string `json:"branch"`
} `json:"trail"`
Sessions []struct {
SessionID string `json:"session_id"`
} `json:"sessions"`
DefaultResume struct {
SessionID string `json:"session_id"`
} `json:"default_resume"`
FindingsSummary struct {
Open int `json:"open"`
OpenHigh int `json:"open_high"`
} `json:"findings_summary"`
Findings []struct {
ID string `json:"id"`
} `json:"findings"`
Commands []string `json:"commands"`
}
if err := json.Unmarshal(out.Bytes(), &decoded); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, out.String())
}
if decoded.Trail.ID != "trl_1" || decoded.Trail.Number != 575 || decoded.Trail.Branch != "feature/trail-resume" {
t.Fatalf("decoded trail = %#v", decoded.Trail)
}
if len(decoded.Sessions) != 1 || decoded.Sessions[0].SessionID != "session-1" {
t.Fatalf("decoded sessions = %#v", decoded.Sessions)
}
if decoded.DefaultResume.SessionID != "session-1" {
t.Fatalf("decoded default_resume = %#v", decoded.DefaultResume)
}
if decoded.FindingsSummary.Open != 1 || decoded.FindingsSummary.OpenHigh != 1 {
t.Fatalf("decoded findings_summary = %#v", decoded.FindingsSummary)
}
if len(decoded.Findings) != 1 || decoded.Findings[0].ID != "finding-1" {
t.Fatalf("decoded findings = %#v", decoded.Findings)
}
}
func TestBuildTrailResumeRestoredSessionChoicesDefaultsToMostRecent(t *testing.T) {
t.Parallel()
oldTime := time.Date(2026, 6, 22, 14, 30, 0, 0, time.UTC)
newTime := time.Date(2026, 6, 23, 7, 39, 0, 0, time.UTC)
choices := buildTrailResumeRestoredSessionChoices([]strategy.RestoredSession{
{
SessionID: "019eefbd-bb6a-7f51-a909-feb4cd95588d",
Agent: types.AgentType("Codex"),
Prompt: "set up the persistent checkpoint contract",
CreatedAt: oldTime,
},
{
SessionID: "019ef36b-a485-7ca2-992b-b4f164266e7f",
Agent: types.AgentType("Codex"),
Prompt: "finish the api/checkpoint extraction",
CreatedAt: newTime,
},
})
if len(choices) != 2 {
t.Fatalf("choices len = %d, want 2", len(choices))
}
if choices[0].SessionID != "019ef36b-a485-7ca2-992b-b4f164266e7f" {
t.Fatalf("first choice = %#v, want most recent restored session", choices[0])
}
if !strings.Contains(choices[0].Label, "default") {
t.Fatalf("first choice label = %q, want default marker", choices[0].Label)
}
if choices[1].SessionID != "019eefbd-bb6a-7f51-a909-feb4cd95588d" {
t.Fatalf("second choice = %#v, want older restored session", choices[1])
}
}
func TestBuildTrailResumeRestoredSessionChoicesPrefersWorkSessionOverReview(t *testing.T) {
t.Parallel()
workTime := time.Date(2026, 6, 23, 7, 30, 0, 0, time.UTC)
reviewTime := workTime.Add(10 * time.Minute)
choices := buildTrailResumeRestoredSessionChoices([]strategy.RestoredSession{
{
SessionID: "work-session",
Agent: types.AgentType("Codex"),
Prompt: "extract the persistent contract",
CreatedAt: workTime,
},
{
SessionID: "review-session",
Agent: types.AgentType("Codex"),
Kind: "agent_review",
ReviewPrompt: "Review the code changes introduced by commit f9000bc1a.",
CreatedAt: reviewTime,
},
})
if len(choices) != 2 {
t.Fatalf("choices len = %d, want 2", len(choices))
}
if choices[0].SessionID != "work-session" {
t.Fatalf("first choice = %#v, want normal work session before newer review session", choices[0])
}
if !strings.Contains(choices[0].Label, "default") {
t.Fatalf("work choice label = %q, want default marker", choices[0].Label)
}
if choices[1].SessionID != "review-session" {
t.Fatalf("second choice = %#v, want review session after work session", choices[1])
}
if !strings.Contains(choices[1].Label, "review") {
t.Fatalf("review choice label = %q, want review marker", choices[1].Label)
}
if !strings.Contains(choices[1].Label, "Review the code changes") {
t.Fatalf("review choice label = %q, want review prompt fallback", choices[1].Label)
}
}
func TestBuildTrailResumeRestoredSessionChoicesPrefersWorkSessionOverReviewPrompt(t *testing.T) {
t.Parallel()
workTime := time.Date(2026, 6, 23, 7, 30, 0, 0, time.UTC)
reviewTime := workTime.Add(10 * time.Minute)
choices := buildTrailResumeRestoredSessionChoices([]strategy.RestoredSession{
{
SessionID: "work-session",
Agent: types.AgentType("Codex"),
Prompt: "extract the persistent contract",
CreatedAt: workTime,
},
{
SessionID: "review-session",
Agent: types.AgentType("Codex"),
Prompt: "Review the code changes introduced by commit f9000bc1a.",
CreatedAt: reviewTime,
},
})
if len(choices) != 2 {
t.Fatalf("choices len = %d, want 2", len(choices))
}
if choices[0].SessionID != "work-session" {
t.Fatalf("first choice = %#v, want work session before newer review prompt", choices[0])
}
if choices[1].SessionID != "review-session" {
t.Fatalf("second choice = %#v, want review prompt after work session", choices[1])
}
if !strings.Contains(choices[1].Label, "review") {
t.Fatalf("review choice label = %q, want review marker", choices[1].Label)
}
}
func TestPrintTrailRestoredSessionSummaryIdentifiesReviewOnlyCheckpointSessions(t *testing.T) {
t.Parallel()
var out strings.Builder
printTrailRestoredSessionSummary(&out, []strategy.RestoredSession{
{
SessionID: "review-session-1",
Kind: string(session.KindAgentReview),
ReviewPrompt: "Review the code changes introduced by commit abc123.",
},
{
SessionID: "review-session-2",
Prompt: "Review this branch for regressions.",
},
})
text := out.String()
for _, want := range []string{
"Restored 2 checkpoint sessions",
"Only review/investigation checkpoint sessions were found",
"may not appear as trail UI sessions",
} {
if !strings.Contains(text, want) {
t.Fatalf("summary missing %q:\n%s", want, text)
}
}
}
func TestDisplayTrailRestoredSessionsIncludesReviewWarning(t *testing.T) {
t.Parallel()
var out strings.Builder
err := displayTrailRestoredSessions(&out, []strategy.RestoredSession{
{
SessionID: "019ef36b-a485-7ca2-992b-b4f164266e7f",
Agent: types.AgentType("Codex"),
Kind: string(session.KindAgentReview),
ReviewPrompt: "Review the code changes introduced by commit abc123.",
CreatedAt: time.Date(2026, 6, 23, 7, 39, 0, 0, time.UTC),
},
})
if err != nil {
t.Fatalf("displayTrailRestoredSessions() error = %v", err)
}
text := out.String()
for _, want := range []string{
"Restored checkpoint session 019ef36b-a485-7ca2-992b-b4f164266e7f",
"Only review/investigation checkpoint sessions were found",
"To continue this checkpoint session:",
"codex resume 019ef36b-a485-7ca2-992b-b4f164266e7f",
"Review the code changes introduced by commit abc123.",
} {
if !strings.Contains(text, want) {
t.Fatalf("display output missing %q:\n%s", want, text)
}
}
}
func TestDisplayTrailRestoredSessionsMarksActualMostRecent(t *testing.T) {
t.Parallel()
workTime := time.Date(2026, 6, 23, 7, 30, 0, 0, time.UTC)
reviewTime := workTime.Add(10 * time.Minute)
var out strings.Builder
err := displayTrailRestoredSessions(&out, []strategy.RestoredSession{
{
SessionID: "work-session",
Agent: agent.AgentTypeClaudeCode,
Prompt: "continue implementation",
CreatedAt: workTime,
},
{
SessionID: "review-session",
Agent: types.AgentType("Codex"),
Kind: string(session.KindAgentReview),
ReviewPrompt: "Review this branch for regressions.",
CreatedAt: reviewTime,
},
})
if err != nil {
t.Fatalf("displayTrailRestoredSessions() error = %v", err)
}
text := out.String()
workLine := lineContaining(text, "claude -r work-session")
if strings.Contains(workLine, "most recent") {
t.Fatalf("work session command should not be marked most recent:\n%s", text)
}
reviewLine := lineContaining(text, "codex resume review-session")
if !strings.Contains(reviewLine, "most recent") {
t.Fatalf("newest review session command should be marked most recent:\n%s", text)
}
}
func lineContaining(text, needle string) string {
for _, line := range strings.Split(text, "\n") {
if strings.Contains(line, needle) {
return line
}
}
return ""
}
func TestTrailResumeWorktreeClashMessage(t *testing.T) {
t.Parallel()
msg := trailResumeWorktreeClashMessage("feature/work", "/tmp/path with spaces")
for _, want := range []string{
`Branch "feature/work" is already checked out in another worktree:`,
"/tmp/path with spaces",
"Resume from that worktree with:",
"cd '/tmp/path with spaces' && entire trail resume feature/work",
} {
if !strings.Contains(msg, want) {
t.Fatalf("message missing %q:\n%s", want, msg)
}
}
}
Acmd/entire/cli/trail_resume_cmd_test.go+586