From 53cfd3090f09c626e6bd370c2d465044954f3fb6 Mon Sep 17 00:00:00 2001 From: Beforerr Date: Fri, 12 Jun 2026 16:36:17 -0700 Subject: [PATCH] feat: assign jj-style session ids; target trace/interrupt/close by id prefix --- README.md | 6 ++-- go/daemon.go | 24 ++++++++++++---- go/engine_test.go | 48 ++++++++++++++++++++++++++++--- go/main.go | 62 +++++++++++++++++++++++++-------------- go/manager.go | 67 +++++++++++++++++++++++++++++++++++++++---- go/parse_test.go | 12 ++++++++ go/session.go | 1 + skills/repld/SKILL.md | 8 +++--- 8 files changed, 184 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index ed9e8ad..61d39fe 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ repld --session LABEL ... # named session, reusable across dirs repld --fresh ... # restart targeted session first repld --trace LEVEL ... # error traceback level: short | smart | full -repld trace [--session=L | exe] # last saved traceback -repld interrupt [--session=L | exe] # interrupt in-flight eval -repld close [--session=L | exe] +repld trace [id | exe | --session=L] # last saved traceback +repld interrupt [id | exe | --session=L] # interrupt in-flight eval +repld close [id | exe | --session=L] repld sessions # list active sessions repld stop # shutdown daemon ``` diff --git a/go/daemon.go b/go/daemon.go index 438795d..3983673 100644 --- a/go/daemon.go +++ b/go/daemon.go @@ -29,7 +29,11 @@ func handleRequest(state *daemonState, req protocolRequest) response { switch req.Action { case "trace": - err := state.manager.lastError(req.Lang, req.Session, req.Cwd, discFor(req)) + key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req)) + if kerr != nil { + return errResp(kerr.Error()) + } + err := state.manager.lastError(key) if err == nil { return errResp("No saved traceback for this session.") } @@ -43,7 +47,7 @@ func handleRequest(state *daemonState, req protocolRequest) response { sort.Slice(sessions, func(i, j int) bool { return sessions[i].lang+sessions[i].label < sessions[j].lang+sessions[j].label }) - lines := []string{"Active sessions:"} + lines := []string{} for _, s := range sessions { label := s.label if strings.HasPrefix(label, "~") { @@ -51,7 +55,9 @@ func handleRequest(state *daemonState, req protocolRequest) response { } else { label = "dir " + label } - line := fmt.Sprintf(" [%s] %s", s.lang, label) + line := "" + line += s.id + " " + line += fmt.Sprintf("[%s] %s", s.lang, label) if s.project != "" { line += " project=" + s.project } @@ -71,14 +77,22 @@ func handleRequest(state *daemonState, req protocolRequest) response { return response{Output: strings.Join(lines, "\n")} case "interrupt": - msg, err := state.manager.interrupt(req.Lang, req.Session, req.Cwd, discFor(req), 3.0) + key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req)) + if kerr != nil { + return errResp(kerr.Error()) + } + msg, err := state.manager.interrupt(key, 3.0) if err != nil { return errResp(err.Error()) } return response{Output: msg} case "close": - msg, err := state.manager.close(req.Lang, req.Session, req.Cwd, discFor(req)) + key, kerr := state.manager.targetKey(req.ID, req.Lang, req.Session, req.Cwd, discFor(req)) + if kerr != nil { + return errResp(kerr.Error()) + } + msg, err := state.manager.close(key) if err != nil { return errResp(err.Error()) } diff --git a/go/engine_test.go b/go/engine_test.go index 6593d57..4071cbd 100644 --- a/go/engine_test.go +++ b/go/engine_test.go @@ -93,6 +93,7 @@ func TestHandleRequest_SessionsList(t *testing.T) { // python dir session. The key is lang-prefixed; --session labels are global. jl := newSession(julia.Adapter{}, "s", []string{"--project=/env"}, nil) jl.lang = "julia" + jl.id = "kqzmkqzm" named := newSession(julia.Adapter{}, "s", nil, nil) named.lang = "julia" py := newSession(python.Adapter{}, "s", nil, nil) @@ -104,10 +105,9 @@ func TestHandleRequest_SessionsList(t *testing.T) { resp := handleRequest(state, protocolRequest{Action: "sessions"}) require.Empty(t, resp.Error) - require.Equal(t, `Active sessions: - [julia] dir /work project=/env args=--project=/env - [julia] session scratch project=@. - [python] dir /work status=dead`, resp.Output) + require.Equal(t, `kqzmkqzm [julia] dir /work project=/env args=--project=/env + [julia] session scratch project=@. + [python] dir /work status=dead`, resp.Output) } func TestInterruptUnknownSession(t *testing.T) { @@ -135,6 +135,46 @@ func TestCloseSession(t *testing.T) { require.False(t, sess.isAlive()) } +func TestCloseSessionByID(t *testing.T) { + state := newTestState() + sess := newSession(julia.Adapter{}, "s", nil, nil) + sess.lang = "julia" + sess.id = "kqzm" + state.manager.sessions["julia\x00/work\x00@."] = sess + + // Unique prefix resolves regardless of cwd. + resp := handleRequest(state, protocolRequest{Action: "close", ID: "kq", Cwd: t.TempDir()}) + require.Empty(t, resp.Error) + require.Contains(t, resp.Output, "closed") + require.Empty(t, state.manager.sessions) + + resp = handleRequest(state, protocolRequest{Action: "close", ID: "kq", Cwd: t.TempDir()}) + require.Contains(t, resp.Error, `no session with id "kq"`) +} + +func TestKeyForIDPrefix(t *testing.T) { + m := newSessionManager() + defer m.shutdown() + a := newSession(julia.Adapter{}, "s", nil, nil) + a.id = "kqzm" + b := newSession(julia.Adapter{}, "s", nil, nil) + b.id = "kxyz" + m.sessions["julia\x00/a\x00@."] = a + m.sessions["julia\x00/b\x00@."] = b + + key, err := m.keyForID("kq") + require.NoError(t, err) + require.Equal(t, "julia\x00/a\x00@.", key) + + _, err = m.keyForID("k") // shared prefix → ambiguous + require.Error(t, err) + require.Contains(t, err.Error(), "ambiguous") + + key, err = m.keyForID("zz") // no match + require.NoError(t, err) + require.Equal(t, "", key) +} + func TestSessionManagerKey(t *testing.T) { m := newSessionManager() defer m.shutdown() diff --git a/go/main.go b/go/main.go index e83e094..cbc0bd3 100644 --- a/go/main.go +++ b/go/main.go @@ -53,6 +53,7 @@ type response struct { type protocolRequest struct { Action string `json:"action"` + ID string `json:"id,omitempty"` // short session id for targeting commands Lang string `json:"lang,omitempty"` Code string `json:"code,omitempty"` Cwd string `json:"cwd,omitempty"` @@ -180,38 +181,40 @@ func cmdEvalFile(socketPath, lang, file, exe, session string, fresh bool, traceL }, true) } -func cmdInterrupt(socketPath, lang, exe, session string, fwd []string) { +func cmdInterrupt(socketPath string, tg subTarget, exe string) { run(socketPath, protocolRequest{ Action: "interrupt", - Lang: lang, + ID: tg.id, + Lang: tg.lang, Exe: exe, Cwd: mustGetwd(), - Session: session, - Args: fwd, + Session: tg.session, + Args: tg.fwd, }, false) } -func cmdClose(socketPath, lang, exe, session string, fwd []string) { +func cmdClose(socketPath string, tg subTarget, exe string) { run(socketPath, protocolRequest{ Action: "close", - Lang: lang, + ID: tg.id, + Lang: tg.lang, Exe: exe, Cwd: mustGetwd(), - Session: session, - Args: fwd, + Session: tg.session, + Args: tg.fwd, }, false) } -func cmdTrace(socketPath, lang, exe, session, traceLevel string, fwd []string) { - traceLevel = cmp.Or(traceLevel, "full") +func cmdTrace(socketPath string, tg subTarget, exe string) { run(socketPath, protocolRequest{ Action: "trace", - Lang: lang, + ID: tg.id, + Lang: tg.lang, Exe: exe, Cwd: mustGetwd(), - Session: session, - TraceLevel: traceLevel, - Args: fwd, + Session: tg.session, + TraceLevel: cmp.Or(tg.level, "full"), + Args: tg.fwd, }, false) } @@ -220,7 +223,7 @@ func usage() { Usage: repld [interp-args] (-- CODE | [--] [script-args] | -) - repld [] [--session L] # target a session: trace, interrupt, close + repld [|] [--session L] # target a session: trace, interrupt, close repld # daemon-wide: sessions, stop, daemon is the interpreter to run (julia, python3, R, wolframscript, .venv/bin/python, /path/...). @@ -234,7 +237,8 @@ repld flags: --fresh Clear the targeted session before evaluating --trace LEVEL Error traceback level: short, smart, or full (eval default: smart) -Commands (trace/interrupt/close take an optional [exe] and/or --session to locate the session): +Commands (trace/interrupt/close locate a session by [exe], --session, or the +short id shown by 'sessions'; an id prefix works when unambiguous): sessions List active sessions (all languages) trace Print the last saved error traceback for the session interrupt Interrupt the in-flight eval (SIGKILL after 3s if unresponsive) @@ -385,8 +389,20 @@ func resolveExeStr(exe, lang string) string { } type subTarget struct { - exe, lang, session, level string - fwd []string + exe, id, lang, session, level string + fwd []string +} + +func looksLikeID(s string) bool { + if len(s) < 2 || len(s) > 2*idLen { + return false + } + for _, c := range s { + if !strings.ContainsRune(idAlphabet, c) { + return false + } + } + return true } func parseTarget(p parsed) subTarget { @@ -395,7 +411,9 @@ func parseTarget(p parsed) subTarget { for i := 0; i < len(a); { s := a[i] if !strings.HasPrefix(s, "-") { - if tg.exe == "" && s != "" { + if tg.id == "" && tg.exe == "" && looksLikeID(s) { + tg.id = s + } else if tg.exe == "" && s != "" { tg.exe = s } else { tg.fwd = append(tg.fwd, s) @@ -476,13 +494,13 @@ func dispatchSubcommand(p parsed) { run(p.socket, protocolRequest{Action: "stop"}, false) case "trace": tg := parseTarget(p) - cmdTrace(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.level, tg.fwd) + cmdTrace(p.socket, tg, resolveExeStr(tg.exe, tg.lang)) case "interrupt": tg := parseTarget(p) - cmdInterrupt(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.fwd) + cmdInterrupt(p.socket, tg, resolveExeStr(tg.exe, tg.lang)) case "close": tg := parseTarget(p) - cmdClose(p.socket, tg.lang, resolveExeStr(tg.exe, tg.lang), tg.session, tg.fwd) + cmdClose(p.socket, tg, resolveExeStr(tg.exe, tg.lang)) case "daemon": fs := flag.NewFlagSet("daemon", flag.ExitOnError) idleTimeout := fs.Float64("idle-timeout", 0, "Shut down after this many idle seconds (0 = never)") diff --git a/go/manager.go b/go/manager.go index c9d5fea..f8920a4 100644 --- a/go/manager.go +++ b/go/manager.go @@ -1,6 +1,7 @@ package main import ( + "crypto/rand" "fmt" "os" "path/filepath" @@ -46,6 +47,60 @@ func (m *SessionManager) key(lang, session, cwd, disc string) string { return lang + "\x00" + route + "\x00" + disc } +// from k-z : IDs never look like exe names, paths +const idAlphabet = "klmnopqrstuvwxyz" +const idLen = 8 + +func (m *SessionManager) uniqueIDLocked() string { + for { + b := make([]byte, idLen) + rand.Read(b) + id := make([]byte, idLen) + for i, c := range b { + id[i] = idAlphabet[int(c)%len(idAlphabet)] + } + inUse := false + for _, sess := range m.sessions { + if sess.id == string(id) { + inUse = true + break + } + } + if !inUse { + return string(id) + } + } +} + +func (m *SessionManager) keyForID(prefix string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + match := "" + for key, sess := range m.sessions { + if sess.id != "" && strings.HasPrefix(sess.id, prefix) { + if match != "" { + return "", fmt.Errorf("session id %q is ambiguous", prefix) + } + match = key + } + } + return match, nil +} + +func (m *SessionManager) targetKey(id, lang, session, cwd, disc string) (string, error) { + if id != "" { + key, err := m.keyForID(id) + if err != nil { + return "", err + } + if key == "" { + return "", fmt.Errorf("no session with id %q", id) + } + return key, nil + } + return m.key(lang, session, cwd, disc), nil +} + // keyLabel is the human label for a key: a "~label" session label, else the cwd // (the lang prefix and project discriminant are shown separately). func keyLabel(key string) string { @@ -105,6 +160,7 @@ func (m *SessionManager) getOrCreate(lang, cwd, session, exe string, fwd []strin } m.mu.Lock() + sess.id = m.uniqueIDLocked() m.sessions[key] = sess m.mu.Unlock() return sess, nil @@ -134,8 +190,7 @@ func (m *SessionManager) restart(lang, session, cwd, disc string) { } } -func (m *SessionManager) close(lang, session, cwd, disc string) (string, error) { - key := m.key(lang, session, cwd, disc) +func (m *SessionManager) close(key string) (string, error) { m.mu.Lock() sess := m.sessions[key] delete(m.sessions, key) @@ -163,14 +218,14 @@ func (m *SessionManager) recordError(lang, session, cwd, disc string, err *evalE m.mu.Unlock() } -func (m *SessionManager) lastError(lang, session, cwd, disc string) *evalError { - key := m.key(lang, session, cwd, disc) +func (m *SessionManager) lastError(key string) *evalError { m.mu.Lock() defer m.mu.Unlock() return m.lastErrors[key] } type sessionInfo struct { + id string lang string label string // session label or cwd project string // environment discriminant (Julia --project); "" if none @@ -187,6 +242,7 @@ func (m *SessionManager) list() []sessionInfo { result := make([]sessionInfo, 0, len(m.sessions)) for key, sess := range m.sessions { info := sessionInfo{ + id: sess.id, lang: sess.lang, label: keyLabel(key), alive: sess.isAlive(), @@ -206,8 +262,7 @@ func (m *SessionManager) list() []sessionInfo { return result } -func (m *SessionManager) interrupt(lang, session, cwd, disc string, graceSecs float64) (string, error) { - key := m.key(lang, session, cwd, disc) +func (m *SessionManager) interrupt(key string, graceSecs float64) (string, error) { m.mu.Lock() sess := m.sessions[key] m.mu.Unlock() diff --git a/go/parse_test.go b/go/parse_test.go index 61b6428..eb8c842 100644 --- a/go/parse_test.go +++ b/go/parse_test.go @@ -98,6 +98,18 @@ func TestParseTargetVerbFirst(t *testing.T) { require.Equal(t, "ml", tg.session) require.Equal(t, "", tg.exe) require.Equal(t, "", tg.lang) + + // A bare token in the id alphabet targets by session id; real exe + // names ("r" is too short, others use letters outside k-z) stay exes. + tg = parseTarget(parseCLI([]string{"close", "kqzm"})) + require.Equal(t, "kqzm", tg.id) + require.Equal(t, "", tg.exe) + tg = parseTarget(parseCLI([]string{"close", "r"})) + require.Equal(t, "", tg.id) + require.Equal(t, "r", tg.exe) + tg = parseTarget(parseCLI([]string{"close", "python"})) + require.Equal(t, "", tg.id) + require.Equal(t, "python", tg.exe) } func TestParseArgsFileMode(t *testing.T) { diff --git a/go/session.go b/go/session.go index 183fbff..af06c9b 100644 --- a/go/session.go +++ b/go/session.go @@ -21,6 +21,7 @@ const startupTimeout = 120.0 type Session struct { adapter Adapter + id string // short auto-assigned handle lang string sentinel string fwd []string diff --git a/skills/repld/SKILL.md b/skills/repld/SKILL.md index 056099b..0008a94 100644 --- a/skills/repld/SKILL.md +++ b/skills/repld/SKILL.md @@ -34,14 +34,14 @@ repld python3 train.py 50 # 50 → sys.argv[1] - Session routing: `--session LABEL` has highest priority. Otherwise sessions are keyed by language plus adapter-specific environment (for example project flag for Julia, interpreter path for Python/R/Wolfram) plus cwd. - Avoid using `--fresh` when a live interpreter can safely pick up changed state. -See [references/julia.md](references/julia.md) for Julia-specific notes. +See [julia.md](references/julia.md), [python.md](references/python.md), [r.md](references/r.md), [wolfram.md](references/wolfram.md) for language-specific notes. ## Commands ```bash -repld trace --session scratch # last saved traceback, no rerun -repld interrupt --session scratch # interrupt stuck eval; state kept on Julia/Python/R, Wolfram = kill -repld sessions # list active sessions +repld sessions # list active sessions, show IDs +repld trace [id | exe | --session=LABEL] # last saved traceback +repld interrupt [id | exe | --session=LABEL] # interrupt eval repld stop # shut down daemon timeout 30 repld julia -e 'might_hang()' # client death interrupts eval