From 94d11f9121bfa1af435f1ac08483e7bc404a3f86 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sat, 1 Aug 2026 19:47:02 +0200 Subject: [PATCH] feat: support empty sessions (Core=nil) for multi-agent prep Create() now registers a Session with Core=nil instead of just reserving a name. All code paths that accessed sess.Core are nil-guarded: - root.go: buildIndex, InterruptAll, rootOpen (proc read), rootDel (proc dismiss) - persist.go: Kill, Rename, Shutdown, waitIdle, persistSession - files.go: handleCtl save command - create.go: Create() creates and registers the empty session Also fixed pathType in server.go which panicked on empty sessions (calling sess.Core.Agent().Name() when Core is nil). The panic was caught by the recovery handler and returned as 'internal error'. AGENTS.md: document cmd/ollie-remote test/build quirk --- AGENTS.md | 11 ++++++-- cmd/olliesrv/server.go | 63 +++++++++++++++++++++++------------------- fs/session/create.go | 16 +++++++++-- fs/session/files.go | 4 ++- fs/session/persist.go | 15 +++++++--- fs/session/root.go | 23 +++++++++++---- 6 files changed, 88 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd1a756..b38afbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,11 +58,18 @@ just clean # remove build artifacts Requires [just](https://github.com/casey/just): `cargo install just` ## Testing ```bash -# All Go tests: -go test ./... +# All Go tests (excludes cmd/ollie-remote which needs just to build): +go test $(go list ./... | grep -v cmd/ollie-remote) # KDE has integration test scripts: cd kde && ./test-e2e.sh +# ollie-remote requires the just build pipeline to resolve embedded deps: +just ollie-remote +just test +# or individually: +just test-remote ``` + +**Note on `cmd/ollie-remote`**: This package uses `//go:embed sandbox/default.yaml` and `//go:embed all:tools`. The embedded files are not in the source tree — `just ollie-remote` copies them in before building then cleans up. Running `go test ./...` will fail on this package. Use `just test-remote` or `go test $(go list ./... | grep -v cmd/ollie-remote)` instead. ## Language & Conventions - **Go** (root module): Go 1.25+, standard library preferred, minimal dependencies. diff --git a/cmd/olliesrv/server.go b/cmd/olliesrv/server.go index dc0d486..1a5e028 100644 --- a/cmd/olliesrv/server.go +++ b/cmd/olliesrv/server.go @@ -15,13 +15,13 @@ import ( "sync" "time" + "ollie/agent" "ollie/backend" - "ollie/toolsrv" "ollie/elevate" + "ollie/fs/session" olog "ollie/log" "ollie/paths" - "ollie/fs/session" - "ollie/agent" + "ollie/toolsrv" "9fans.net/go/plan9" ) @@ -573,6 +573,10 @@ func (s *Server) pathType(path string) string { if len(parts) == 2 { return "dir" } + // No agent in empty session. + if sess.Core == nil { + return "" + } // Validate agent Name agentName := sess.Core.Agent().Name() if agentName == "" || parts[2] != agentName { @@ -600,6 +604,9 @@ func (s *Server) pathType(path string) string { } // /session/{id}/proc/{pid} — verify PID exists in detached list if len(parts) == 3 && parts[1] == "proc" { + if sess.Core == nil { + return "" + } for _, p := range sess.Core.Agent().ListDetached() { if fmt.Sprintf("%d", p.PID) == parts[2] { return "file" @@ -837,19 +844,19 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan return s.readDNS(fc, &f.generateResult) } -// /route: return per-fid result from previous write (offset 0-based) - if path == "/route" { - s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) - return s.readDNS(fc, &f.routeResult) - } - - // /session/new: return per-fid result from previous write - if path == "/session/new" { - s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) - return s.readDNS(fc, &f.sessionResult) - } - + // /route: return per-fid result from previous write (offset 0-based) + if path == "/route" { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) + return s.readDNS(fc, &f.routeResult) + } + + // /session/new: return per-fid result from previous write + if path == "/session/new" { + s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) + return s.readDNS(fc, &f.sessionResult) + } + + s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) entry, err := s.openEntry(path) if err != nil { return errFcall(fc, err.Error()) @@ -1017,22 +1024,22 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if err != nil { return errFcall(fc, err.Error()) } -f.routeResult = []byte(fmt.Sprintf("backend=%s model=%s", result.Backend, result.Model)) - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} - } + f.routeResult = []byte(fmt.Sprintf("backend=%s model=%s", result.Backend, result.Model)) + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } - // /session/new: synchronous create-session (per-fid state, like /net/dns) - if f.path == "/session/new" { - cs.mu.Unlock() - name, err := fs.CreateFromRoot(s.sessionTree, strings.Fields(strings.TrimSpace(string(fc.Data)))) - if err != nil { - return errFcall(fc, err.Error()) - } - f.sessionResult = []byte(name) - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + // /session/new: synchronous create-session (per-fid state, like /net/dns) + if f.path == "/session/new" { + cs.mu.Unlock() + name, err := fs.CreateFromRoot(s.sessionTree, strings.Fields(strings.TrimSpace(string(fc.Data)))) + if err != nil { + return errFcall(fc, err.Error()) } + f.sessionResult = []byte(name) + return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} + } - // Accumulate; the 9P client may split large writes across multiple Twrite messages. + // Accumulate; the 9P client may split large writes across multiple Twrite messages. end := int(fc.Offset) + len(fc.Data) if end > len(f.writeBuf) { grown := make([]byte, end) diff --git a/fs/session/create.go b/fs/session/create.go index 3b86973..7a3c9c2 100644 --- a/fs/session/create.go +++ b/fs/session/create.go @@ -14,8 +14,9 @@ import ( "ollie/toolsrv" ) -// Create creates a new session from key=value args. Returns the session name. -// The session is NOT registered yet — CreateAgent must be called to finalize it. +// Create creates a new empty session from key=value args and registers it. +// Returns the session name. The session has no agent (Core is nil) until +// CreateAgent is called. func Create(rs *rootState, args []string) (string, error) { name := "" for _, arg := range args { @@ -49,7 +50,16 @@ func Create(rs *rootState, args []string) (string, error) { return "", fmt.Errorf("session already exists: %s", name) } - rs.cfg.Log.Info("reserved session name %s (id=%s)", name, sessID) + sessionCtx, sessionCancel := context.WithCancel(rs.cfg.Ctx) + sess := NewSession(sessID, nil, sessionCtx, sessionCancel) + sess.SetName(name) + + rs.mu.Lock() + rs.sessions[name] = sess + rs.mu.Unlock() + + rs.cfg.Log.Info("created empty session %s (id=%s)", name, sessID) + broadcastEvents() return name, nil } diff --git a/fs/session/files.go b/fs/session/files.go index 7054472..ff36697 100644 --- a/fs/session/files.go +++ b/fs/session/files.go @@ -172,7 +172,9 @@ func (h *sessionHelper) handleCtl(input string) error { } } case "save": - h.sess.Core.SaveSession("") // triggers session persistence + if h.sess.Core != nil { + h.sess.Core.SaveSession("") // triggers session persistence + } case "invalidate": if h.invalidateModels != nil { h.invalidateModels() diff --git a/fs/session/persist.go b/fs/session/persist.go index f6c1d4d..8586e47 100644 --- a/fs/session/persist.go +++ b/fs/session/persist.go @@ -24,7 +24,9 @@ func Kill(rs *rootState, id string) { rs.mu.Unlock() if sess != nil { sess.Cancel() - sess.Core.Close() + if sess.Core != nil { + sess.Core.Close() + } if sess.proc != nil { sess.proc.Close() } @@ -52,7 +54,7 @@ func Rename(rs *rootState, old, new string) error { rs.mu.Unlock() return fmt.Errorf("session already exists: %s", new) } - if sess.Core.Agent().IsRunning() { + if sess.Core != nil && sess.Core.Agent().IsRunning() { rs.mu.Unlock() return fmt.Errorf("cannot rename while agent is running") } @@ -91,7 +93,9 @@ func Shutdown(root *Tree) { rs.mu.Unlock() if sess != nil { sess.Cancel() - sess.Core.Close() + if sess.Core != nil { + sess.Core.Close() + } rs.cfg.Log.Info("shutdown session %s", id) } } @@ -103,7 +107,7 @@ func waitIdle(rs *rootState, poll, timeout time.Duration) { allIdle := true rs.mu.RLock() for _, sess := range rs.sessions { - if sess.Core.Agent().State() != "idle" { + if sess.Core != nil && sess.Core.Agent().State() != "idle" { allIdle = false break } @@ -130,6 +134,9 @@ func persistSession(rs *rootState, id string) { if !ok { return } + if sess.Core == nil { + return + } dir := activeSessionsDir(rs) os.MkdirAll(dir, 0700) path := filepath.Join(dir, sess.id+".json") diff --git a/fs/session/root.go b/fs/session/root.go index 27c794a..e52d390 100644 --- a/fs/session/root.go +++ b/fs/session/root.go @@ -194,7 +194,9 @@ func InterruptAll(root *Tree) { rs.mu.RLock() defer rs.mu.RUnlock() for _, sess := range rs.sessions { - sess.Core.Agent().Interrupt(agent.ErrInterrupted) + if sess.Core != nil { + sess.Core.Agent().Interrupt(agent.ErrInterrupted) + } } } @@ -389,6 +391,9 @@ func rootOpen(rs *rootState, name string) (File, error) { return &SyntheticFileInfo{Name_: pidStr, Mode_: 0444}, nil }, ReadFn: func() ([]byte, error) { + if sess.Core == nil { + return nil, fmt.Errorf("no agent in session") + } output, err := sess.Core.Agent().GetDetachedOutput(pid) if err != nil { return nil, err @@ -531,6 +536,9 @@ func rootDel(rs *rootState, name string) error { if err != nil { return fmt.Errorf("invalid pid: %s", parts[2]) } + if sess.Core == nil { + return fmt.Errorf("no agent in session") + } if !sess.Core.Agent().DismissDetached(pid) { return fmt.Errorf("process %d not found or still running", pid) } @@ -550,12 +558,15 @@ func buildIndex(rs *rootState) []byte { for _, id := range ids { sess := rs.sessions[id] sess.mu.RLock() - st := sess.Core.Agent().State() - cwd := sess.Core.CWD() - be := sess.Core.Agent().BackendName() - model := sess.Core.Agent().ModelName() - ag := sess.Core.Agent().Name() name := sess.name + var st, cwd, be, model, ag string + if sess.Core != nil { + st = sess.Core.Agent().State() + cwd = sess.Core.CWD() + be = sess.Core.Agent().BackendName() + model = sess.Core.Agent().ModelName() + ag = sess.Core.Agent().Name() + } sess.mu.RUnlock() fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\t%s\n", name, st, cwd, be, model, ag) }