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:
parent
daf535308f
commit
94d11f9121
11
AGENTS.md
11
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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue