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
This commit is contained in:
Ollie Agent 2026-08-01 19:47:02 +02:00
parent daf535308f
commit 94d11f9121
6 changed files with 88 additions and 44 deletions

View File

@ -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.

View File

@ -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"

View File

@ -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
}

View File

@ -172,7 +172,9 @@ func (h *sessionHelper) handleCtl(input string) error {
}
}
case "save":
if h.sess.Core != nil {
h.sess.Core.SaveSession("") // triggers session persistence
}
case "invalidate":
if h.invalidateModels != nil {
h.invalidateModels()

View File

@ -24,7 +24,9 @@ func Kill(rs *rootState, id string) {
rs.mu.Unlock()
if sess != nil {
sess.Cancel()
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()
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")

View File

@ -194,9 +194,11 @@ func InterruptAll(root *Tree) {
rs.mu.RLock()
defer rs.mu.RUnlock()
for _, sess := range rs.sessions {
if sess.Core != nil {
sess.Core.Agent().Interrupt(agent.ErrInterrupted)
}
}
}
// OpenSessionTree returns a Tree for the given session ID.
func OpenSessionTree(root *Tree, id string) (*Tree, error) {
@ -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)
}