detached 3e037ba

fix(session): harden resume lookup cancellation

commit
3e037ba3083d6a0284183655ee1d29a9f37edba0
author
brice · · 2026-08-08 06:54 UTC
parent
443b176
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +110 -19
M packages/agent/cli.go +11 -7
@@ -899,7 +899,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
899899 }
900900 }()
901901 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
902- sess, err := openOrCreateSession(args, r, ag, version)
902+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
903903 if err != nil {
904904 return err
905905 }
@@ -972,7 +972,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
972972 }
973973 }()
974974 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
975- sess, err := openOrCreateSession(args, r, ag, version)
975+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
976976 if err != nil {
977977 return err
978978 }
@@ -1084,7 +1084,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
10841084 }
10851085 }()
10861086 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1087- sess, err := openOrCreateSession(args, r, ag, version)
1087+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
10881088 if err != nil {
10891089 return err
10901090 }
@@ -1716,7 +1716,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
17161716 var sessionTitlePending bool
17171717 if !args.NoSess && ag != nil {
17181718 var sessErr error
1719- sess, sessErr = openOrCreateSession(args, r, ag, version)
1719+ sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
17201720 if sessErr != nil {
17211721 return sessErr
17221722 }
@@ -2599,10 +2599,14 @@ func resumableSessionID(root string, sess *core.Session) string {
25992599 if sess == nil || sess.ID == "" || sess.Path == "" {
26002600 return ""
26012601 }
2602- path, err := core.FindManagedSessionByID(root, sess.ID)
2602+ path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
26032603 if err != nil || !sameSessionPath(path, sess.Path) {
26042604 return ""
26052605 }
2606+ snapshot, err := core.ReadSessionSnapshot(path)
2607+ if err != nil || len(snapshot.Messages) == 0 {
2608+ return ""
2609+ }
26062610 return sess.ID
26072611 }
26082612
@@ -2628,7 +2632,7 @@ func agentSessionsRoot(root string, args Args) string {
26282632
26292633 // openOrCreateSession returns a session for the run. sess may be nil
26302634 // with a nil error if session persistence is disabled.
2631-func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2635+func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
26322636 if args.NoSess {
26332637 return nil, nil
26342638 }
@@ -2667,7 +2671,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
26672671 }
26682672 case args.Resume:
26692673 if args.ResumeSessionID != "" {
2670- picked, lookupErr := core.FindManagedSessionByID(ZutHome(), args.ResumeSessionID)
2674+ picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
26712675 if lookupErr != nil {
26722676 return nil, lookupErr
26732677 }
@@ -899,7 +899,7 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
899 }899 }
900 }()900 }()
901 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)901 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
902- sess, err := openOrCreateSession(args, r, ag, version)902+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
903 if err != nil {903 if err != nil {
904 return err904 return err
905 }905 }
@@ -972,7 +972,7 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
972 }972 }
973 }()973 }()
974 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)974 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
975- sess, err := openOrCreateSession(args, r, ag, version)975+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
976 if err != nil {976 if err != nil {
977 return err977 return err
978 }978 }
@@ -1084,7 +1084,7 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
1084 }1084 }
1085 }()1085 }()
1086 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)1086 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
1087- sess, err := openOrCreateSession(args, r, ag, version)1087+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
1088 if err != nil {1088 if err != nil {
1089 return err1089 return err
1090 }1090 }
@@ -1716,7 +1716,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1716 var sessionTitlePending bool1716 var sessionTitlePending bool
1717 if !args.NoSess && ag != nil {1717 if !args.NoSess && ag != nil {
1718 var sessErr error1718 var sessErr error
1719- sess, sessErr = openOrCreateSession(args, r, ag, version)1719+ sess, sessErr = openOrCreateSession(ctx, args, r, ag, version)
1720 if sessErr != nil {1720 if sessErr != nil {
1721 return sessErr1721 return sessErr
1722 }1722 }
@@ -2599,10 +2599,14 @@ func resumableSessionID(root string, sess *core.Session) string {
2599 if sess == nil || sess.ID == "" || sess.Path == "" {2599 if sess == nil || sess.ID == "" || sess.Path == "" {
2600 return ""2600 return ""
2601 }2601 }
2602- path, err := core.FindManagedSessionByID(root, sess.ID)2602+ path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2603 if err != nil || !sameSessionPath(path, sess.Path) {2603 if err != nil || !sameSessionPath(path, sess.Path) {
2604 return ""2604 return ""
2605 }2605 }
2606+ snapshot, err := core.ReadSessionSnapshot(path)
2607+ if err != nil || len(snapshot.Messages) == 0 {
2608+ return ""
2609+ }
2606 return sess.ID2610 return sess.ID
2607 }2611 }
2608 2612
@@ -2628,7 +2632,7 @@ func agentSessionsRoot(root string, args Args) string {
2628 2632
2629 // openOrCreateSession returns a session for the run. sess may be nil2633 // openOrCreateSession returns a session for the run. sess may be nil
2630 // with a nil error if session persistence is disabled.2634 // with a nil error if session persistence is disabled.
2631-func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {2635+func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
2632 if args.NoSess {2636 if args.NoSess {
2633 return nil, nil2637 return nil, nil
2634 }2638 }
@@ -2667,7 +2671,7 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
2667 }2671 }
2668 case args.Resume:2672 case args.Resume:
2669 if args.ResumeSessionID != "" {2673 if args.ResumeSessionID != "" {
2670- picked, lookupErr := core.FindManagedSessionByID(ZutHome(), args.ResumeSessionID)2674+ picked, lookupErr := core.FindManagedSessionByID(ctx, ZutHome(), args.ResumeSessionID)
2671 if lookupErr != nil {2675 if lookupErr != nil {
2672 return nil, lookupErr2676 return nil, lookupErr
2673 }2677 }
M packages/agent/cli_session_test.go +57 -4
@@ -40,7 +40,7 @@ func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
4040 }
4141
4242 ag := core.NewAgent(nil, "model", "", nil)
43- got, err := openOrCreateSession(Args{
43+ got, err := openOrCreateSession(context.Background(), Args{
4444 CWD: otherCWD,
4545 Resume: true,
4646 ResumeSessionID: id,
@@ -75,7 +75,7 @@ func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
7575 }
7676
7777 namedAgent := core.NewAgent(nil, "model", "", nil)
78- namedGot, err := openOrCreateSession(Args{
78+ namedGot, err := openOrCreateSession(context.Background(), Args{
7979 CWD: otherCWD,
8080 Resume: true,
8181 ResumeSessionID: namedID,
@@ -131,13 +131,66 @@ func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
131131 }
132132 }
133133
134+func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
135+ root := t.TempDir()
136+ cwd := t.TempDir()
137+ otherCWD := t.TempDir()
138+ t.Setenv("ZUT_HOME", root)
139+
140+ dir := core.SessionsDir(root, cwd)
141+ if err := os.MkdirAll(dir, 0o755); err != nil {
142+ t.Fatal(err)
143+ }
144+ id := uuid.NewString()
145+ metaLine := struct {
146+ Type string `json:"type"`
147+ Meta core.SessionMeta `json:"meta"`
148+ }{
149+ Type: "meta",
150+ Meta: core.SessionMeta{
151+ ID: id,
152+ CWD: cwd,
153+ Provider: "provider",
154+ Model: "model",
155+ Started: time.Now().UTC(),
156+ Version: "test",
157+ },
158+ }
159+ data, err := json.Marshal(metaLine)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ path := filepath.Join(dir, "persisted-empty.jsonl")
164+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
165+ t.Fatal(err)
166+ }
167+
168+ sess, err := openOrCreateSession(context.Background(), Args{
169+ CWD: otherCWD,
170+ Resume: true,
171+ ResumeSessionID: id,
172+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ if sess == nil || sess.Path != path {
177+ t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
178+ }
179+ if got := resumableSessionID(root, sess); got != "" {
180+ t.Fatalf("persisted empty session hint ID = %q, want empty", got)
181+ }
182+ if err := sess.Close(); err != nil {
183+ t.Fatal(err)
184+ }
185+}
186+
134187 func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
135188 root := t.TempDir()
136189 cwd := t.TempDir()
137190 t.Setenv("ZUT_HOME", root)
138191 id := uuid.NewString()
139192
140- _, err := openOrCreateSession(Args{
193+ _, err := openOrCreateSession(context.Background(), Args{
141194 CWD: cwd,
142195 Resume: true,
143196 ResumeSessionID: id,
@@ -164,7 +217,7 @@ func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
164217 t.Fatal(err)
165218 }
166219
167- _, err := openOrCreateSession(Args{
220+ _, err := openOrCreateSession(context.Background(), Args{
168221 CWD: cwd,
169222 Resume: true,
170223 ResumeSessionID: uuid.NewString(),
@@ -40,7 +40,7 @@ func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
40 }40 }
41 41
42 ag := core.NewAgent(nil, "model", "", nil)42 ag := core.NewAgent(nil, "model", "", nil)
43- got, err := openOrCreateSession(Args{43+ got, err := openOrCreateSession(context.Background(), Args{
44 CWD: otherCWD,44 CWD: otherCWD,
45 Resume: true,45 Resume: true,
46 ResumeSessionID: id,46 ResumeSessionID: id,
@@ -75,7 +75,7 @@ func TestOpenOrCreateSessionResumesByUUIDAcrossCWDAndAgentStores(t *testing.T) {
75 }75 }
76 76
77 namedAgent := core.NewAgent(nil, "model", "", nil)77 namedAgent := core.NewAgent(nil, "model", "", nil)
78- namedGot, err := openOrCreateSession(Args{78+ namedGot, err := openOrCreateSession(context.Background(), Args{
79 CWD: otherCWD,79 CWD: otherCWD,
80 Resume: true,80 Resume: true,
81 ResumeSessionID: namedID,81 ResumeSessionID: namedID,
@@ -131,13 +131,66 @@ func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
131 }131 }
132 }132 }
133 133
134+func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
135+ root := t.TempDir()
136+ cwd := t.TempDir()
137+ otherCWD := t.TempDir()
138+ t.Setenv("ZUT_HOME", root)
139+
140+ dir := core.SessionsDir(root, cwd)
141+ if err := os.MkdirAll(dir, 0o755); err != nil {
142+ t.Fatal(err)
143+ }
144+ id := uuid.NewString()
145+ metaLine := struct {
146+ Type string `json:"type"`
147+ Meta core.SessionMeta `json:"meta"`
148+ }{
149+ Type: "meta",
150+ Meta: core.SessionMeta{
151+ ID: id,
152+ CWD: cwd,
153+ Provider: "provider",
154+ Model: "model",
155+ Started: time.Now().UTC(),
156+ Version: "test",
157+ },
158+ }
159+ data, err := json.Marshal(metaLine)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ path := filepath.Join(dir, "persisted-empty.jsonl")
164+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
165+ t.Fatal(err)
166+ }
167+
168+ sess, err := openOrCreateSession(context.Background(), Args{
169+ CWD: otherCWD,
170+ Resume: true,
171+ ResumeSessionID: id,
172+ }, Resolved{Provider: "provider", Model: "model"}, core.NewAgent(nil, "model", "", nil), "test")
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ if sess == nil || sess.Path != path {
177+ t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
178+ }
179+ if got := resumableSessionID(root, sess); got != "" {
180+ t.Fatalf("persisted empty session hint ID = %q, want empty", got)
181+ }
182+ if err := sess.Close(); err != nil {
183+ t.Fatal(err)
184+ }
185+}
186+
134 func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {187 func TestOpenOrCreateSessionUnknownUUIDDoesNotCreateSession(t *testing.T) {
135 root := t.TempDir()188 root := t.TempDir()
136 cwd := t.TempDir()189 cwd := t.TempDir()
137 t.Setenv("ZUT_HOME", root)190 t.Setenv("ZUT_HOME", root)
138 id := uuid.NewString()191 id := uuid.NewString()
139 192
140- _, err := openOrCreateSession(Args{193+ _, err := openOrCreateSession(context.Background(), Args{
141 CWD: cwd,194 CWD: cwd,
142 Resume: true,195 Resume: true,
143 ResumeSessionID: id,196 ResumeSessionID: id,
@@ -164,7 +217,7 @@ func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
164 t.Fatal(err)217 t.Fatal(err)
165 }218 }
166 219
167- _, err := openOrCreateSession(Args{220+ _, err := openOrCreateSession(context.Background(), Args{
168 CWD: cwd,221 CWD: cwd,
169 Resume: true,222 Resume: true,
170 ResumeSessionID: uuid.NewString(),223 ResumeSessionID: uuid.NewString(),
M packages/agent/subagent_worker.go +1 -1
@@ -70,7 +70,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
7070 }
7171 }()
7272 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
73- sess, err := openOrCreateSession(args, r, ag, version)
73+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
7474 if err != nil {
7575 return err
7676 }
@@ -70,7 +70,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
70 }70 }
71 }()71 }()
72 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)72 wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
73- sess, err := openOrCreateSession(args, r, ag, version)73+ sess, err := openOrCreateSession(ctx, args, r, ag, version)
74 if err != nil {74 if err != nil {
75 return err75 return err
76 }76 }
M packages/core/session_portable.go +28 -5
@@ -3,6 +3,7 @@ package core
33 import (
44 "bufio"
55 "bytes"
6+ "context"
67 "encoding/json"
78 "errors"
89 "fmt"
@@ -803,6 +804,13 @@ func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNod
803804 // small projection; full snapshot validation remains the responsibility of
804805 // resume, export, branching, and tree preflight callers.
805806 func scanSessionMeta(path string) (meta SessionMeta, err error) {
807+ return scanSessionMetaContext(context.Background(), path)
808+}
809+
810+func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
811+ if err := contextErr(ctx); err != nil {
812+ return SessionMeta{}, err
813+ }
806814 f, err := os.Open(path)
807815 if err != nil {
808816 return SessionMeta{}, err
@@ -810,7 +818,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) {
810818 defer f.Close()
811819
812820 var sawMeta bool
813- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {
821+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
814822 var head sessionLineHead
815823 if err := json.Unmarshal(line, &head); err != nil {
816824 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -886,7 +894,7 @@ func FindSessionByID(root, cwd, id string) string {
886894 // session stores, independent of cwd. A nil error and empty path mean no
887895 // matching session was found. Read, storage, metadata, and duplicate-ID
888896 // failures are returned to the caller instead of being reported as a miss.
889-func FindManagedSessionByID(root, id string) (string, error) {
897+func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
890898 if strings.TrimSpace(root) == "" {
891899 return "", errors.New("find managed session: root is empty")
892900 }
@@ -910,10 +918,16 @@ func FindManagedSessionByID(root, id string) (string, error) {
910918
911919 var matches []string
912920 for _, store := range stores {
913- if err := collectManagedSessionMatches(store, id, &matches); err != nil {
921+ if err := contextErr(ctx); err != nil {
922+ return "", err
923+ }
924+ if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
914925 return "", err
915926 }
916927 }
928+ if err := contextErr(ctx); err != nil {
929+ return "", err
930+ }
917931 if len(matches) == 0 {
918932 return "", nil
919933 }
@@ -923,7 +937,10 @@ func FindManagedSessionByID(root, id string) (string, error) {
923937 return matches[0], nil
924938 }
925939
926-func collectManagedSessionMatches(root, id string, matches *[]string) error {
940+func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
941+ if err := contextErr(ctx); err != nil {
942+ return err
943+ }
927944 dir := filepath.Join(root, "sessions")
928945 entries, err := os.ReadDir(dir)
929946 if errors.Is(err, os.ErrNotExist) {
@@ -933,6 +950,9 @@ func collectManagedSessionMatches(root, id string, matches *[]string) error {
933950 return fmt.Errorf("find managed session: read %q: %w", dir, err)
934951 }
935952 for _, bucket := range entries {
953+ if err := contextErr(ctx); err != nil {
954+ return err
955+ }
936956 if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
937957 continue
938958 }
@@ -942,11 +962,14 @@ func collectManagedSessionMatches(root, id string, matches *[]string) error {
942962 return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
943963 }
944964 for _, file := range files {
965+ if err := contextErr(ctx); err != nil {
966+ return err
967+ }
945968 if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
946969 continue
947970 }
948971 path := filepath.Join(bucketPath, file.Name())
949- meta, err := scanSessionMeta(path)
972+ meta, err := scanSessionMetaContext(ctx, path)
950973 if err != nil {
951974 return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
952975 }
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "bufio"4 "bufio"
5 "bytes"5 "bytes"
6+ "context"
6 "encoding/json"7 "encoding/json"
7 "errors"8 "errors"
8 "fmt"9 "fmt"
@@ -803,6 +804,13 @@ func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNod
803 // small projection; full snapshot validation remains the responsibility of804 // small projection; full snapshot validation remains the responsibility of
804 // resume, export, branching, and tree preflight callers.805 // resume, export, branching, and tree preflight callers.
805 func scanSessionMeta(path string) (meta SessionMeta, err error) {806 func scanSessionMeta(path string) (meta SessionMeta, err error) {
807+ return scanSessionMetaContext(context.Background(), path)
808+}
809+
810+func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
811+ if err := contextErr(ctx); err != nil {
812+ return SessionMeta{}, err
813+ }
806 f, err := os.Open(path)814 f, err := os.Open(path)
807 if err != nil {815 if err != nil {
808 return SessionMeta{}, err816 return SessionMeta{}, err
@@ -810,7 +818,7 @@ func scanSessionMeta(path string) (meta SessionMeta, err error) {
810 defer f.Close()818 defer f.Close()
811 819
812 var sawMeta bool820 var sawMeta bool
813- err = forEachStrictJSONLLine(f, func(line []byte, lineNo int) error {821+ err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
814 var head sessionLineHead822 var head sessionLineHead
815 if err := json.Unmarshal(line, &head); err != nil {823 if err := json.Unmarshal(line, &head); err != nil {
816 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)824 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
@@ -886,7 +894,7 @@ func FindSessionByID(root, cwd, id string) string {
886 // session stores, independent of cwd. A nil error and empty path mean no894 // session stores, independent of cwd. A nil error and empty path mean no
887 // matching session was found. Read, storage, metadata, and duplicate-ID895 // matching session was found. Read, storage, metadata, and duplicate-ID
888 // failures are returned to the caller instead of being reported as a miss.896 // failures are returned to the caller instead of being reported as a miss.
889-func FindManagedSessionByID(root, id string) (string, error) {897+func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
890 if strings.TrimSpace(root) == "" {898 if strings.TrimSpace(root) == "" {
891 return "", errors.New("find managed session: root is empty")899 return "", errors.New("find managed session: root is empty")
892 }900 }
@@ -910,10 +918,16 @@ func FindManagedSessionByID(root, id string) (string, error) {
910 918
911 var matches []string919 var matches []string
912 for _, store := range stores {920 for _, store := range stores {
913- if err := collectManagedSessionMatches(store, id, &matches); err != nil {921+ if err := contextErr(ctx); err != nil {
922+ return "", err
923+ }
924+ if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
914 return "", err925 return "", err
915 }926 }
916 }927 }
928+ if err := contextErr(ctx); err != nil {
929+ return "", err
930+ }
917 if len(matches) == 0 {931 if len(matches) == 0 {
918 return "", nil932 return "", nil
919 }933 }
@@ -923,7 +937,10 @@ func FindManagedSessionByID(root, id string) (string, error) {
923 return matches[0], nil937 return matches[0], nil
924 }938 }
925 939
926-func collectManagedSessionMatches(root, id string, matches *[]string) error {940+func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
941+ if err := contextErr(ctx); err != nil {
942+ return err
943+ }
927 dir := filepath.Join(root, "sessions")944 dir := filepath.Join(root, "sessions")
928 entries, err := os.ReadDir(dir)945 entries, err := os.ReadDir(dir)
929 if errors.Is(err, os.ErrNotExist) {946 if errors.Is(err, os.ErrNotExist) {
@@ -933,6 +950,9 @@ func collectManagedSessionMatches(root, id string, matches *[]string) error {
933 return fmt.Errorf("find managed session: read %q: %w", dir, err)950 return fmt.Errorf("find managed session: read %q: %w", dir, err)
934 }951 }
935 for _, bucket := range entries {952 for _, bucket := range entries {
953+ if err := contextErr(ctx); err != nil {
954+ return err
955+ }
936 if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {956 if !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
937 continue957 continue
938 }958 }
@@ -942,11 +962,14 @@ func collectManagedSessionMatches(root, id string, matches *[]string) error {
942 return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)962 return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
943 }963 }
944 for _, file := range files {964 for _, file := range files {
965+ if err := contextErr(ctx); err != nil {
966+ return err
967+ }
945 if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {968 if file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
946 continue969 continue
947 }970 }
948 path := filepath.Join(bucketPath, file.Name())971 path := filepath.Join(bucketPath, file.Name())
949- meta, err := scanSessionMeta(path)972+ meta, err := scanSessionMetaContext(ctx, path)
950 if err != nil {973 if err != nil {
951 return fmt.Errorf("find managed session: read metadata %q: %w", path, err)974 return fmt.Errorf("find managed session: read metadata %q: %w", path, err)
952 }975 }
M packages/core/session_portable_test.go +13 -2
@@ -1,7 +1,9 @@
11 package core
22
33 import (
4+ "context"
45 "encoding/json"
6+ "errors"
57 "os"
68 "path/filepath"
79 "strings"
@@ -27,7 +29,7 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(
2729 t.Fatal(err)
2830 }
2931
30- got, err := FindManagedSessionByID(root, session.ID)
32+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
3133 if err != nil {
3234 t.Fatalf("FindManagedSessionByID: %v", err)
3335 }
@@ -39,13 +41,22 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(
3941 if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
4042 t.Fatal(err)
4143 }
42- if _, err := FindManagedSessionByID(root, "missing"); err == nil {
44+ if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil {
4345 t.Fatal("malformed managed metadata was reported as not found")
4446 } else if !strings.Contains(err.Error(), "metadata") {
4547 t.Fatalf("malformed metadata error = %v, want metadata context", err)
4648 }
4749 }
4850
51+func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ cancel()
54+
55+ if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) {
56+ t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err)
57+ }
58+}
59+
4960 // TestSessionExportImportRoundTrip writes a few messages to a live
5061 // session, exports it, imports the export under a different cwd,
5162 // and verifies OpenSession on the imported file yields the same
@@ -1,7 +1,9 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
6+ "errors"
5 "os"7 "os"
6 "path/filepath"8 "path/filepath"
7 "strings"9 "strings"
@@ -27,7 +29,7 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(
27 t.Fatal(err)29 t.Fatal(err)
28 }30 }
29 31
30- got, err := FindManagedSessionByID(root, session.ID)32+ got, err := FindManagedSessionByID(context.Background(), root, session.ID)
31 if err != nil {33 if err != nil {
32 t.Fatalf("FindManagedSessionByID: %v", err)34 t.Fatalf("FindManagedSessionByID: %v", err)
33 }35 }
@@ -39,13 +41,22 @@ func TestFindManagedSessionByIDStrictScopesAllManagedStoresAndReturnsReadErrors(
39 if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {41 if err := os.WriteFile(badPath, []byte("not json\n"), 0o644); err != nil {
40 t.Fatal(err)42 t.Fatal(err)
41 }43 }
42- if _, err := FindManagedSessionByID(root, "missing"); err == nil {44+ if _, err := FindManagedSessionByID(context.Background(), root, "missing"); err == nil {
43 t.Fatal("malformed managed metadata was reported as not found")45 t.Fatal("malformed managed metadata was reported as not found")
44 } else if !strings.Contains(err.Error(), "metadata") {46 } else if !strings.Contains(err.Error(), "metadata") {
45 t.Fatalf("malformed metadata error = %v, want metadata context", err)47 t.Fatalf("malformed metadata error = %v, want metadata context", err)
46 }48 }
47 }49 }
48 50
51+func TestFindManagedSessionByIDHonorsCancellation(t *testing.T) {
52+ ctx, cancel := context.WithCancel(context.Background())
53+ cancel()
54+
55+ if _, err := FindManagedSessionByID(ctx, t.TempDir(), "session-id"); !errors.Is(err, context.Canceled) {
56+ t.Fatalf("FindManagedSessionByID error = %v, want context.Canceled", err)
57+ }
58+}
59+
49 // TestSessionExportImportRoundTrip writes a few messages to a live60 // TestSessionExportImportRoundTrip writes a few messages to a live
50 // session, exports it, imports the export under a different cwd,61 // session, exports it, imports the export under a different cwd,
51 // and verifies OpenSession on the imported file yields the same62 // and verifies OpenSession on the imported file yields the same

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close