sessions: a DeepSeek Harness session can be deleted — its folder, its subagents' and their cached listing rows go to magpie's trash, and Restore puts them back (lc on Discord: deepseek harness无法删除会话)

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): <sessions>/--<cwd>--/<id escaped by encodeSegment>/ 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/<id>.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.
This commit is contained in:
yetone committed 2026-10-07 06:17:56 +08:00
1 parent 11ec99de71
commit 2c2137beb7
3 files changed
+257 -3

No files matched your search

+49
View File
@@ -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: <sessions>/<project>/<the id, escaped>/, 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):
// <dsh>/storages/session_projcache/sessions/<id>.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) }
+188
View File
@@ -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/--<cwd>--/<escaped id>/ 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/<id>.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)
}
}
}
+20 -3
View File
@@ -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)