From 2c2137beb7219523e3da558fbb6b5acc561c0acb Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 7 Oct 2026 05:59:11 +0800 Subject: [PATCH] =?UTF-8?q?sessions:=20a=20DeepSeek=20Harness=20session=20?= =?UTF-8?q?can=20be=20deleted=20=E2=80=94=20its=20folder,=20its=20subagent?= =?UTF-8?q?s'=20and=20their=20cached=20listing=20rows=20go=20to=20magpie's?= =?UTF-8?q?=20trash,=20and=20Restore=20puts=20them=20back=20(lc=20on=20Dis?= =?UTF-8?q?cord:=20deepseek=20harness=E6=97=A0=E6=B3=95=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletable left dsh out, so the Sessions page said magpie could list dsh's sessions but not delete them. dsh keeps each session as files of its own (dsh-session-persistence-jsonl, root $DSH_HOME/sessions from dsh-base's cordis.patch.yml): /----// with every format generation and session.lock; a subagent's session is a folder of its own whose header names its parent. dsh's listing caches a row per session at storages/session_projcache/sessions/.json (per-record dsh-storage-json); dsh itself deletes nothing ("Nothing deletes session files"), so there is no index to keep in step. The delete moves each of the group's session folders and its cached row. A folder is taken only when its name is the session id as dsh escapes it and it sits two levels under the sessions root; when any file of the group isn't in such a folder nothing is moved, so another session's files never go with it. Tested: TestDshDeleteRestore on dsh 0.2.0-rc.2's real layout (zstd frames, session.v4 plus a v3 left behind, session.lock, a subagent, a second session in the same project folder that stays untouched, ErrActive while written), TestDshDeleteOnlyItsOwnFolder, TestDshSegment (checked against dsh's own encodeSegment run in node). Fails with dsh out of Deletable. With the real dsh 0.2.0-rc.2 in a sandbox DSH_HOME: two `dsh headless` sessions, magpie deleted one (folder + cached row), restore gave a tree identical to the original (diff -r), deleted again: `dsh headless --session-id` on the other still resumes it, and on the deleted one says it does not exist. go test -race -count=5 of the delete tests, go vet, gofmt, go test -tags nogui ./... pass. No GUI change: the page already offers delete when the API says deletable. Siblings checked: Cline keeps sessions.db as its session list (its own deleteSession removes the rows, checkpoint refs and files), so moving files alone would leave it listing them; Grok Build keeps session_search.sqlite; Droid's layout is only known from a closed binary. They stay list-only. --- internal/sessions/dsh.go | 49 +++++++ internal/sessions/dsh_manage_test.go | 188 +++++++++++++++++++++++++++ internal/sessions/manage.go | 23 +++- 3 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 internal/sessions/dsh_manage_test.go diff --git a/internal/sessions/dsh.go b/internal/sessions/dsh.go index 76e98f8f..262460a6 100644 --- a/internal/sessions/dsh.go +++ b/internal/sessions/dsh.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "strconv" "strings" "sync" + "unicode/utf16" "github.com/yetone/magpie/internal/appdir" ) @@ -125,6 +127,53 @@ func dshFiles() []file { return out } +// dshSessionDir is the folder dsh keeps a session file's session in, all +// of it that session's own: ///, with +// the session's generations and its session.lock. "" when the file isn't +// in the folder its id names, so a delete never takes another's. +func dshSessionDir(f file) string { + if f.agent != "dsh" || f.sid == "" { + return "" + } + d := filepath.Dir(f.path) + if filepath.Base(d) != dshSegment(f.sid) || filepath.Dir(filepath.Dir(d)) != filepath.Join(DshDir(), "sessions") { + return "" + } + return d +} + +// dshSegment is a session id as dsh names its folder (encodeSegment in +// dsh-session-persistence-jsonl): A–Z, a–z, 0–9, '.', '_' and '-' kept, +// every other UTF-16 unit ~XXXX, and "." and ".." escaped whole. +func dshSegment(id string) string { + switch id { + case ".": + return "~002E" + case "..": + return "~002E~002E" + } + var b strings.Builder + for _, u := range utf16.Encode([]rune(id)) { + if u < 0x80 && (u >= 'A' && u <= 'Z' || u >= 'a' && u <= 'z' || u >= '0' && u <= '9' || u == '.' || u == '_' || u == '-') { + b.WriteByte(byte(u)) + } else { + fmt.Fprintf(&b, "~%04X", u) + } + } + return b.String() +} + +// dshCacheRecord is the row dsh's session listing caches of a session +// (dsh-session-projection-cache, on dsh-storage-json's per-record layout): +// /storages/session_projcache/sessions/.json. "" for an id that +// layout can't hold. +func dshCacheRecord(id string) string { + if !safeID.MatchString(id) { + return "" + } + return filepath.Join(DshDir(), "storages", "session_projcache", "sessions", id+".json") +} + // dshOpen reads a session file's lines, decompressed. func dshOpen(path string) (io.ReadCloser, error) { return openLines(path) } diff --git a/internal/sessions/dsh_manage_test.go b/internal/sessions/dsh_manage_test.go new file mode 100644 index 00000000..ffb4188a --- /dev/null +++ b/internal/sessions/dsh_manage_test.go @@ -0,0 +1,188 @@ +package sessions + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/klauspost/compress/zstd" +) + +// dsh 0.2's own layout (dsh-session-persistence-jsonl, dsh-base's +// cordis.patch.yml; as dsh 0.2.0-rc.2's `dsh headless` wrote it): $DSH_HOME/sessions/----// holds a +// session's generations, zstd frames by default (the header's, then one a +// batch), and its session.lock; a subagent's session is a folder of its own +// whose header names its parent; the listing's cache keeps a row a session +// at storages/session_projcache/sessions/.json. +const ( + dshP = "session-4f1c2a9e-6b3d-4e8a-9c71-2d5e8f0a1b3c" // the session deleted + dshC = "session-a7e2c4d1-0f9b-4c3e-8d26-5b1a9e7f3c40" // its subagent's + dshO = "session-c93b1e5f-2a4d-4b7c-9e18-6f0d3a2c5b97" // another, in the same project + dshCwd = "/Users/me/work/app" + dshProjD = "--Users-me-work-app--" +) + +func dshFrames(t *testing.T, lines ...string) []byte { + t.Helper() + enc, err := zstd.NewWriter(nil, zstd.WithEncoderCRC(true)) + if err != nil { + t.Fatal(err) + } + defer enc.Close() + // the header in a frame of its own, then the events a batch at a time + out := enc.EncodeAll([]byte(lines[0]+"\n"), nil) + return enc.EncodeAll([]byte(strings.Join(lines[1:], "\n")+"\n"), out) +} + +func dshSession(t *testing.T, home, id, head, prompt string) string { + t.Helper() + dir := filepath.Join(home, "sessions", dshProjD, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + b := dshFrames(t, head, + `{"type":"user/message","seq":0,"time":1790503201000,"data":{"content":[{"type":"text","text":"`+prompt+`"}],"source":{"kind":"user"},"role":"user"}}`, + `{"type":"assistant/message","seq":1,"time":1790503260000,"data":{"message":{"source":{"model":"deepseek-v4-pro"}},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":1000}}}`) + os.WriteFile(filepath.Join(dir, "session.v4.jsonl.zstd"), b, 0o600) + os.WriteFile(filepath.Join(dir, "session.lock"), nil, 0o600) + cache := filepath.Join(home, "storages", "session_projcache", "sessions") + os.MkdirAll(cache, 0o700) + os.WriteFile(filepath.Join(cache, id+".json"), []byte(`{"version":7,"record":{}}`), 0o600) + return dir +} + +func dshManageSetup(t *testing.T) (home string) { + manageSetup(t) + home = filepath.Join(t.TempDir(), "dsh") + t.Setenv("DSH_HOME", home) + p := dshSession(t, home, dshP, `{"type":"session","version":4,"id":"`+dshP+`","createdAt":1790503200000,"cwd":"`+dshCwd+`","isSeeded":false,"delegationDepth":0}`, "Tidy the build script") + // the generation it was carried over from stays behind in its folder + os.WriteFile(filepath.Join(p, "session.v3.jsonl.zstd"), dshFrames(t, `{"type":"session","version":3,"id":"`+dshP+`","createdAt":1790503200000,"cwd":"`+dshCwd+`","isSeeded":false,"delegationDepth":0}`, `{"type":"session/title","seq":0,"time":1790503200100,"data":{"title":"x"}}`), 0o600) + dshSession(t, home, dshC, `{"type":"session","version":4,"id":"`+dshC+`","createdAt":1790503230000,"cwd":"`+dshCwd+`","isSeeded":false,"delegationDepth":1,"parentSession":"`+dshP+`","origin":"subagent"}`, "Find the flaky test") + dshSession(t, home, dshO, `{"type":"session","version":4,"id":"`+dshO+`","createdAt":1790503300000,"cwd":"`+dshCwd+`","isSeeded":false,"delegationDepth":0}`, "Write the release notes") + ageDsh(home) + Reset() + return home +} + +func ageDsh(home string) { + old := time.Now().Add(-24 * time.Hour) + filepath.WalkDir(home, func(p string, d os.DirEntry, err error) error { + if err == nil { + os.Chtimes(p, old, old) + } + return nil + }) +} + +// lc on Discord: "deepseek harness无法删除会话". A dsh session goes to the +// trash whole — its folder, its subagent's, their cached rows — and +// nothing of another session's goes with it. +func TestDshDeleteRestore(t *testing.T) { + home := dshManageSetup(t) + list := ListAgent("dsh") + if len(list) != 2 { + t.Fatalf("dsh sessions %d, want 2 (the subagent in its parent)", len(list)) + } + m, ok := findManaged(list, dshP) + if !ok || !m.Deletable || m.Files != 2 || m.Cwd != dshCwd { + t.Fatalf("listed as %+v", m) + } + for _, a := range Agents() { + if a.Agent == "dsh" && !a.Deletable { + t.Fatal("dsh not deletable") + } + } + proj := filepath.Join(home, "sessions", dshProjD) + cache := filepath.Join(home, "storages", "session_projcache", "sessions") + pDir, cDir, oDir := filepath.Join(proj, dshP), filepath.Join(proj, dshC), filepath.Join(proj, dshO) + + // one being written to is left alone + now := time.Now() + os.Chtimes(filepath.Join(cDir, "session.v4.jsonl.zstd"), now, now) + if _, err := Delete("dsh", dshP); !errors.Is(err, ErrActive) { + t.Fatalf("a session just written: %v", err) + } + ageDsh(home) + + tr, err := Delete("dsh", dshP) + if err != nil { + t.Fatal(err) + } + if len(tr.Items) != 4 || tr.Cwd != dshCwd || tr.Title != "Tidy the build script" { + t.Fatalf("trashed %+v, want its folder, its subagent's and their two cached rows", tr) + } + for _, p := range []string{pDir, cDir, filepath.Join(cache, dshP+".json"), filepath.Join(cache, dshC+".json")} { + if _, err := os.Lstat(p); err == nil { + t.Fatalf("%s is still there", p) + } + } + // the other session, its row and the project folder are as they were + for _, p := range []string{filepath.Join(oDir, "session.v4.jsonl.zstd"), filepath.Join(oDir, "session.lock"), filepath.Join(cache, dshO+".json")} { + if _, err := os.Stat(p); err != nil { + t.Fatalf("another session's %s: %v", p, err) + } + } + if l := ListAgent("dsh"); len(l) != 1 || l[0].ID != dshO { + t.Fatalf("after the delete: %+v", l) + } + + if _, err := Restore(tr.Key); err != nil { + t.Fatal(err) + } + for _, p := range []string{filepath.Join(pDir, "session.v4.jsonl.zstd"), filepath.Join(pDir, "session.v3.jsonl.zstd"), filepath.Join(pDir, "session.lock"), filepath.Join(cDir, "session.v4.jsonl.zstd"), filepath.Join(cache, dshP+".json"), filepath.Join(cache, dshC+".json")} { + if _, err := os.Stat(p); err != nil { + t.Fatalf("%s isn't back: %v", p, err) + } + } + Reset() + if s, ok := findSession(List(0), "dsh", dshP); !ok || s.Title != "Tidy the build script" { + t.Fatalf("the restored session: %+v", s) + } +} + +// A session file that isn't in the folder dsh names by its id is no folder +// of its own to take: nothing is moved. +func TestDshDeleteOnlyItsOwnFolder(t *testing.T) { + home := dshManageSetup(t) + proj := filepath.Join(home, "sessions", dshProjD) + // the other session's file, in a folder named as dsh wouldn't + odd := filepath.Join(proj, "shared") + os.Rename(filepath.Join(proj, dshO), odd) + os.WriteFile(filepath.Join(odd, "notes.txt"), []byte("someone's"), 0o600) + ageDsh(home) + Reset() + if _, ok := findManaged(ListAgent("dsh"), dshO); !ok { + t.Fatal("the session isn't listed") + } + if _, err := Delete("dsh", dshO); err == nil { + t.Fatal("deleted a folder that isn't the session's") + } + if _, err := os.Stat(filepath.Join(odd, "notes.txt")); err != nil { + t.Fatal("the folder was touched") + } + if len(Trash()) != 0 { + t.Fatal("something went to the trash") + } +} + +func TestDshSegment(t *testing.T) { + for in, want := range map[string]string{ + dshP: dshP, + "a~b": "a~007Eb", + "a/b": "a~002Fb", + ".": "~002E", + "..": "~002E~002E", + "x.y": "x.y", + "会话": "~4F1A~8BDD", + "😀": "~D83D~DE00", + "a b_c": "a~0020b_c", + } { + if got := dshSegment(in); got != want { + t.Errorf("dshSegment(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/sessions/manage.go b/internal/sessions/manage.go index b299a3b9..e363b4f3 100644 --- a/internal/sessions/manage.go +++ b/internal/sessions/manage.go @@ -22,9 +22,11 @@ import ( // // Only the agents whose sessions are files of their own can be deleted: // Claude Code's (and Qoder's and WorkBuddy's, kept the same way), Codex's, -// Pi's, omp's and Cursor CLI's (a chat's folder, its store and meta.json). +// Pi's, omp's, Cursor CLI's (a chat's folder, its store and meta.json) and +// DeepSeek Harness's (a session's folder, and its listing's cached row). // Hermes's and Alma's are rows in a database the agent keeps open and -// writes, so they are only listed. The agents' indexes are left as they are: Codex's +// writes, so they are only listed, and so are Cline's, whose sessions.db +// lists a session whose files are gone. The agents' indexes are left as they are: Codex's // session_index.jsonl (names by thread id) and its state database, and // Claude Code's history.jsonl (the prompts typed, for the up arrow), are // written by the agent while it runs, and a name or a prompt left for a @@ -56,7 +58,7 @@ type AgentCount struct { // those kept as files of their own, in a layout magpie knows whole. func Deletable(agent string) bool { switch agent { - case "claude", "qoder", "qoder-cn", "workbuddy", "codex", "pi", "omp", "cursor": + case "claude", "qoder", "qoder-cn", "workbuddy", "codex", "pi", "omp", "cursor", "dsh": return true } return false @@ -290,6 +292,21 @@ func sessionPaths(agent, id string, fs []file) []string { } return out } + if agent == "dsh" { + // each session's own folder (the parent's and its subagents'), and + // the row dsh caches of it for its listing; none at all when one + // isn't in a folder of its own, rather than half the session + for _, f := range fs { + if dshSessionDir(f) == "" { + return nil + } + } + for _, f := range fs { + add(dshSessionDir(f)) + add(dshCacheRecord(f.sid)) + } + return out + } for _, f := range fs { if f.main { add(f.path)