627 lines
16 KiB
Go
627 lines
16 KiB
Go
package fs
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/simonfxr/pubsub"
|
|
|
|
"ollie/agent"
|
|
"ollie/backend"
|
|
"ollie/paths"
|
|
coresession "ollie/session"
|
|
"ollie/toolsrv"
|
|
)
|
|
|
|
// globalRootState is set by NewRoot so handler functions can access
|
|
// the session map without going through ctx.Root (which is nil at
|
|
// build time when closures are created).
|
|
var globalRootState *RootState
|
|
|
|
// RootState is the package-level state stored in root.Data.
|
|
// Exported for use by fshandlers package.
|
|
type RootState struct {
|
|
Cfg Config
|
|
Mu sync.RWMutex
|
|
Sessions map[string]*Session
|
|
}
|
|
|
|
func state(root *Tree) *RootState {
|
|
return root.Data.(*RootState)
|
|
}
|
|
|
|
var eventBus = pubsub.NewBus()
|
|
|
|
// Event represents a published event with topic and payload.
|
|
type Event struct {
|
|
Topic string
|
|
Payload string
|
|
}
|
|
|
|
// PublishEvent publishes an event to a specific topic.
|
|
// Topic hierarchy:
|
|
// session.{sid}.new/kill/pause/resume/rename
|
|
// session.{sid}.agent.{aid}.new/kill/state
|
|
//
|
|
// Wildcard routing publishes to all ancestor wildcards:
|
|
// session.{sid}.agent.{aid}.state -> also publishes to:
|
|
// session.{sid}.agent.{aid}.*
|
|
// session.{sid}.agent.*
|
|
// session.{sid}.*
|
|
// session.*
|
|
// *
|
|
func PublishEvent(topic, payload string) {
|
|
ev := Event{Topic: topic, Payload: payload}
|
|
eventBus.Publish(topic, ev)
|
|
|
|
// Publish to hierarchical wildcards
|
|
parts := strings.Split(topic, ".")
|
|
for i := len(parts) - 1; i >= 1; i-- {
|
|
wildcard := strings.Join(parts[:i], ".") + ".*"
|
|
eventBus.Publish(wildcard, ev)
|
|
}
|
|
eventBus.Publish("*", ev)
|
|
}
|
|
|
|
// SubscribeEvents returns a channel that receives all events matching the topic.
|
|
// Use "*" for all events, "session.*" for all session events, etc.
|
|
func SubscribeEvents(ctx context.Context, topic string) <-chan Event {
|
|
ch := make(chan Event, 64)
|
|
sub := eventBus.SubscribeChan(topic, ch, pubsub.KeepOpen)
|
|
go func() {
|
|
<-ctx.Done()
|
|
eventBus.Unsubscribe(sub)
|
|
close(ch)
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
// WaitEvent blocks until an event is available. Returns the event as "topic payload\n".
|
|
// For backwards compatibility with the old eventwait interface.
|
|
func WaitEvent(ctx context.Context, _ string) ([]byte, string, error) {
|
|
ch := SubscribeEvents(ctx, "*")
|
|
select {
|
|
case ev, ok := <-ch:
|
|
if !ok {
|
|
return nil, "", nil
|
|
}
|
|
line := ev.Topic
|
|
if ev.Payload != "" {
|
|
line += " " + ev.Payload
|
|
}
|
|
return []byte(line + "\n"), "", nil
|
|
case <-ctx.Done():
|
|
return nil, "", nil
|
|
}
|
|
}
|
|
|
|
// BuildIndex builds the session/idx content.
|
|
//
|
|
// Format: one line per session (not per agent)
|
|
// Fields (tab-separated):
|
|
// name, id, paused, connected, cwd, backend, model, agents
|
|
// Where agents is a semicolon-separated list of: agentName:agentId:state
|
|
//
|
|
// Example:
|
|
// myproject\tf60604bf-...\tfalse\ttrue\t/home/user/proj\tkiro\tclaude-opus-4.5\tdev:7c36c9e4:idle;test:abc123:thinking
|
|
|
|
func BuildIndex(rs *RootState) []byte {
|
|
var sb strings.Builder
|
|
rs.Mu.RLock()
|
|
ids := make([]string, 0, len(rs.Sessions))
|
|
for id := range rs.Sessions {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
for _, id := range ids {
|
|
sess := rs.Sessions[id]
|
|
sess.mu.RLock()
|
|
name := sess.name
|
|
sid := sess.id
|
|
paused := sess.paused
|
|
|
|
// Check connected status
|
|
connected := false
|
|
if conn := sess.toolsConn; conn != nil {
|
|
if pinger, ok := conn.(interface{ Ping() error }); ok {
|
|
connected = pinger.Ping() == nil
|
|
} else {
|
|
connected = true
|
|
}
|
|
}
|
|
|
|
var cwd, be, model string
|
|
var agentList []string
|
|
if sess.Core != nil {
|
|
cwd = sess.Core.CWD()
|
|
for _, ag := range sess.Core.Agents() {
|
|
if be == "" {
|
|
be = ag.BackendName()
|
|
model = ag.ModelName()
|
|
}
|
|
// Format: name:id:state
|
|
agentList = append(agentList, fmt.Sprintf("%s:%s:%s", ag.Name(), ag.ID(), ag.State()))
|
|
}
|
|
}
|
|
agents := strings.Join(agentList, ";")
|
|
fmt.Fprintf(&sb, "%s\t%s\t%t\t%t\t%s\t%s\t%s\t%s\n", name, sid, paused, connected, cwd, be, model, agents)
|
|
sess.mu.RUnlock()
|
|
}
|
|
rs.Mu.RUnlock()
|
|
return []byte(sb.String())
|
|
}
|
|
|
|
// Kill terminates a session and removes it from the tree.
|
|
func Kill(rs *RootState, id string) {
|
|
rs.Mu.Lock()
|
|
sess := rs.Sessions[id]
|
|
if sess == nil {
|
|
rs.Mu.Unlock()
|
|
rs.Cfg.Log.Error("Kill: session %q not found in sessions map", id)
|
|
return
|
|
}
|
|
delete(rs.Sessions, id)
|
|
rs.Mu.Unlock()
|
|
|
|
var agentNames []string
|
|
if sess.Core != nil {
|
|
for _, ag := range sess.Core.Agents() {
|
|
agentNames = append(agentNames, ag.ID())
|
|
}
|
|
sess.Core.Close()
|
|
}
|
|
sess.Cancel()
|
|
if sess.keeper != nil {
|
|
sess.keeper.Close()
|
|
} else if sess.proc != nil {
|
|
sess.proc.Close()
|
|
}
|
|
removePersistedAgent(rs, sess.id)
|
|
rs.Cfg.Log.Info("killed session %s", id)
|
|
for _, agentID := range agentNames {
|
|
PublishEvent("session."+sess.id+".agent."+agentID+".kill", "")
|
|
}
|
|
PublishEvent("session."+sess.id+".kill", "")
|
|
}
|
|
|
|
// Rename renames a session by updating its Name and the sessions map.
|
|
func Rename(rs *RootState, old, new string) error {
|
|
rs.Mu.Lock()
|
|
sess, ok := rs.Sessions[old]
|
|
if !ok {
|
|
rs.Mu.Unlock()
|
|
return fmt.Errorf("session not found: %s", old)
|
|
}
|
|
if _, exists := rs.Sessions[new]; exists {
|
|
rs.Mu.Unlock()
|
|
return fmt.Errorf("session already exists: %s", new)
|
|
}
|
|
if sess.Core != nil {
|
|
for _, ag := range sess.Core.Agents() {
|
|
if ag.IsRunning() {
|
|
rs.Mu.Unlock()
|
|
return fmt.Errorf("cannot rename while agent is running")
|
|
}
|
|
}
|
|
}
|
|
sess.name = new
|
|
rs.Sessions[new] = sess
|
|
delete(rs.Sessions, old)
|
|
rs.Mu.Unlock()
|
|
// Persist the updated session and agent display names immediately. The
|
|
// agent's own autosave does not know the fs-level session display name.
|
|
persistSession(rs, new)
|
|
rs.Cfg.Log.Info("renamed session %s -> %s", old, new)
|
|
PublishEvent("session."+sess.id+".rename", old+" "+new)
|
|
return nil
|
|
}
|
|
|
|
// Shutdown interrupts all sessions, waits for idle, persists, and closes.
|
|
func Shutdown(root *Tree) {
|
|
rs := root.Data.(*RootState)
|
|
InterruptAll(root)
|
|
waitIdle(rs, 100*time.Millisecond, 5*time.Second)
|
|
saveAllSessions(rs)
|
|
rs.Mu.Lock()
|
|
ids := make([]string, 0, len(rs.Sessions))
|
|
for id := range rs.Sessions {
|
|
ids = append(ids, id)
|
|
}
|
|
rs.Mu.Unlock()
|
|
for _, id := range ids {
|
|
rs.Mu.Lock()
|
|
sess := rs.Sessions[id]
|
|
delete(rs.Sessions, id)
|
|
rs.Mu.Unlock()
|
|
if sess != nil {
|
|
sess.Cancel()
|
|
if sess.Core != nil {
|
|
sess.Core.Close()
|
|
}
|
|
if sess.keeper != nil {
|
|
sess.keeper.Close()
|
|
} else if sess.proc != nil {
|
|
sess.proc.Close()
|
|
}
|
|
rs.Cfg.Log.Info("shutdown session %s", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitIdle(rs *RootState, poll, timeout time.Duration) {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
allIdle := true
|
|
rs.Mu.RLock()
|
|
for _, sess := range rs.Sessions {
|
|
if sess.Core != nil {
|
|
for _, ag := range sess.Core.Agents() {
|
|
if ag.State() != "idle" {
|
|
allIdle = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !allIdle {
|
|
break
|
|
}
|
|
}
|
|
rs.Mu.RUnlock()
|
|
if allIdle {
|
|
return
|
|
}
|
|
time.Sleep(poll)
|
|
}
|
|
rs.Cfg.Log.Warn("shutdown: timed out waiting for sessions to become idle, saving anyway")
|
|
}
|
|
|
|
// InterruptAll interrupts every active session.
|
|
func InterruptAll(root *Tree) {
|
|
rs := root.Data.(*RootState)
|
|
rs.Mu.RLock()
|
|
defer rs.Mu.RUnlock()
|
|
for _, sess := range rs.Sessions {
|
|
if sess.Core != nil {
|
|
for _, ag := range sess.Core.Agents() {
|
|
ag.Interrupt(agent.ErrInterrupted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lookup returns the session for the given ID, or nil.
|
|
func Lookup(root *Tree, id string) *Session {
|
|
rs := root.Data.(*RootState)
|
|
rs.Mu.RLock()
|
|
defer rs.Mu.RUnlock()
|
|
// Try by display name first (map key).
|
|
if s := rs.Sessions[id]; s != nil {
|
|
return s
|
|
}
|
|
// Fall back to immutable session ID.
|
|
for _, sess := range rs.Sessions {
|
|
if sess.id == id {
|
|
return sess
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nextUname(rs *RootState) string {
|
|
b := make([]byte, 6)
|
|
rand.Read(b)
|
|
return fmt.Sprintf("%06x%06x", b[:3], b[3:6])
|
|
}
|
|
|
|
// Create creates a new empty session from key=value args and registers it.
|
|
func Create(rs *RootState, args []string) (string, error) {
|
|
name := ""
|
|
for _, arg := range args {
|
|
k, v, ok := strings.Cut(arg, "=")
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid option %q (expected key=value)", arg)
|
|
}
|
|
if v == "" {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "name":
|
|
name = v
|
|
default:
|
|
return "", fmt.Errorf("unknown option %q (valid: name)", k)
|
|
}
|
|
}
|
|
|
|
sessID := coresession.NewSessionID()
|
|
if name == "" {
|
|
name = sessID
|
|
if idx := strings.IndexByte(sessID, '-'); idx > 0 {
|
|
name = sessID[:idx]
|
|
}
|
|
}
|
|
|
|
rs.Mu.RLock()
|
|
_, exists := rs.Sessions[name]
|
|
rs.Mu.RUnlock()
|
|
if exists {
|
|
return "", fmt.Errorf("session already exists: %s", name)
|
|
}
|
|
|
|
sessionCtx, sessionCancel := context.WithCancel(rs.Cfg.Ctx)
|
|
sess := NewSession(sessID, nil, sessionCtx, sessionCancel)
|
|
sess.SetName(name)
|
|
|
|
// Spawn the tool server so the session is immediately operational.
|
|
cwd, _ := os.Getwd()
|
|
var dialOpts []toolsrv.Option
|
|
if rs.Cfg.Yolo {
|
|
dialOpts = append(dialOpts, toolsrv.WithYolo())
|
|
}
|
|
proc, err := toolsrv.Spawn(sessionCtx, cwd, dialOpts...)
|
|
if err != nil {
|
|
sessionCancel()
|
|
return "", fmt.Errorf("spawn tool server: %w", err)
|
|
}
|
|
sess.proc = proc
|
|
sess.keeper = toolsrv.NewProcessKeeper(sessionCtx, proc, func(ctx context.Context) (*toolsrv.Process, error) {
|
|
return toolsrv.Spawn(ctx, cwd, dialOpts...)
|
|
})
|
|
conn, err := toolsrv.Dial(sessionCtx, toolsrv.LocalAddr(proc.Socket))
|
|
if err != nil {
|
|
sessionCancel()
|
|
proc.Close()
|
|
return "", fmt.Errorf("dial tool server: %w", err)
|
|
}
|
|
sess.toolsConn = conn
|
|
|
|
rs.Mu.Lock()
|
|
rs.Sessions[name] = sess
|
|
rs.Mu.Unlock()
|
|
|
|
rs.Cfg.Log.Info("created empty session %s (id=%s)", name, sessID)
|
|
PublishEvent("session."+sessID+".new", name)
|
|
return name, nil
|
|
}
|
|
|
|
// CreateFromRoot is the public API for callers that have a *Tree.
|
|
func CreateFromRoot(root *Tree, args []string) (string, error) {
|
|
return Create(root.Data.(*RootState), args)
|
|
}
|
|
|
|
// CreateAgent creates and configures an agent within a session.
|
|
func CreateAgent(rs *RootState, sessName string, args []string) error {
|
|
backendOverride := ""
|
|
modelOverride := ""
|
|
profile := ""
|
|
name := ""
|
|
cwd := ""
|
|
remoteTarget := ""
|
|
systemPromptOverride := ""
|
|
for _, arg := range args {
|
|
k, v, ok := strings.Cut(arg, "=")
|
|
if !ok {
|
|
return fmt.Errorf("invalid option %q (expected key=value)", arg)
|
|
}
|
|
if v == "" {
|
|
continue
|
|
}
|
|
switch k {
|
|
case "name":
|
|
name = v
|
|
case "backend":
|
|
backendOverride = v
|
|
case "model":
|
|
modelOverride = v
|
|
case "agent":
|
|
profile = v
|
|
case "cwd":
|
|
cwd = v
|
|
case "remote":
|
|
remoteTarget = v
|
|
case "system_prompt":
|
|
systemPromptOverride = v
|
|
default:
|
|
return fmt.Errorf("unknown option %q (valid: name, backend, model, agent, cwd, remote, system_prompt)", k)
|
|
}
|
|
}
|
|
|
|
rs.Mu.RLock()
|
|
sess, ok := rs.Sessions[sessName]
|
|
rs.Mu.RUnlock()
|
|
if !ok {
|
|
sessID := coresession.NewSessionID()
|
|
sessionCtx, sessionCancel := context.WithCancel(rs.Cfg.Ctx)
|
|
sess = NewSession(sessID, nil, sessionCtx, sessionCancel)
|
|
sess.SetName(sessName)
|
|
}
|
|
|
|
if profile == "" {
|
|
if v := os.Getenv("OLLIE_DEFAULT_AGENT"); v != "" {
|
|
profile = v
|
|
} else {
|
|
profile = "default"
|
|
}
|
|
}
|
|
|
|
cwd = paths.ExpandHome(os.ExpandEnv(cwd))
|
|
if cwd == "" {
|
|
return fmt.Errorf("cwd is required (set cwd=<path> when creating an agent)")
|
|
}
|
|
if remoteTarget == "" {
|
|
if info, err := os.Stat(cwd); err != nil {
|
|
return fmt.Errorf("cwd %q does not exist", cwd)
|
|
} else if !info.IsDir() {
|
|
return fmt.Errorf("cwd %q is not a directory", cwd)
|
|
}
|
|
}
|
|
|
|
uname := nextUname(rs)
|
|
sessID := sess.id
|
|
|
|
cfg := LoadAgentConfig(rs.Cfg.AgentsDir, profile, nil)
|
|
if cfg != nil {
|
|
if backendOverride == "" && cfg.Backend != "" {
|
|
backendOverride = cfg.Backend
|
|
}
|
|
if modelOverride == "" && cfg.Model != "" {
|
|
modelOverride = cfg.Model
|
|
}
|
|
}
|
|
|
|
be, err := backend.NewWithName(backendOverride)
|
|
if err != nil {
|
|
return fmt.Errorf("backend: %w", err)
|
|
}
|
|
|
|
if modelOverride == "" {
|
|
modelOverride = os.Getenv("OLLIE_MODEL")
|
|
}
|
|
if modelOverride != "" {
|
|
be.SetModel(modelOverride)
|
|
}
|
|
|
|
if err := rs.Cfg.MkdirAll(rs.Cfg.SessionsDir, PermMkdirPrivate); err != nil {
|
|
return fmt.Errorf("sessions dir: %w", err)
|
|
}
|
|
|
|
// Set up tool server infrastructure.
|
|
// If session already has a proc from Create(), reuse it.
|
|
var reuseFrom *InfraConfig
|
|
if sess.proc != nil && remoteTarget == "" {
|
|
reuseFrom = &InfraConfig{
|
|
Proc: sess.proc,
|
|
Keeper: sess.keeper,
|
|
ToolsConn: sess.toolsConn,
|
|
}
|
|
}
|
|
|
|
infra, err := SetupToolServer(ToolServerConfig{
|
|
Ctx: rs.Cfg.Ctx,
|
|
CWD: cwd,
|
|
RemoteTarget: remoteTarget,
|
|
Yolo: rs.Cfg.Yolo,
|
|
ReuseFrom: reuseFrom,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update session's keeper if we spawned new infrastructure.
|
|
if reuseFrom == nil {
|
|
sess.keeper = infra.Keeper
|
|
}
|
|
|
|
// Build prompt layers.
|
|
layers := BuildPromptLayers(cfg, cwd, sessID, uname, infra.PromptEnv, systemPromptOverride)
|
|
|
|
toolSrv := infra.ToolsConn
|
|
if toolSrv == nil {
|
|
// Tool server is vital infrastructure — retry once with a fresh spawn.
|
|
rs.Cfg.Log.Error("session %s: initial tool server dial failed, retrying...", sessName)
|
|
if sess.keeper != nil {
|
|
if conn, err := sess.keeper.Dial(); err == nil {
|
|
toolSrv = conn
|
|
}
|
|
}
|
|
if toolSrv == nil {
|
|
return fmt.Errorf("tool server unavailable after retry (session infrastructure broken — try again)")
|
|
}
|
|
}
|
|
|
|
// Load configured tools before building the runtime.
|
|
LoadAutoLoadTools(cfg, toolSrv, func(f string, a ...any) {
|
|
rs.Cfg.Log.Error("session %s: "+f, append([]any{sessName}, a...)...)
|
|
})
|
|
|
|
env := []string{"OLLIE_SESSION_ID=" + sessID, "OLLIE_UNAME=" + uname}
|
|
env = append(env, infra.PromptEnv...)
|
|
rt := agent.BuildRuntime(cfg, toolSrv, cwd, env, layers.SystemPrompt, layers.OpModel, layers.EnvBlock)
|
|
sess.toolsConn = toolSrv
|
|
|
|
al := NewAgentLog(remoteTarget)
|
|
|
|
if sess.Core == nil {
|
|
core := coresession.New(coresession.Config{
|
|
Backend: be,
|
|
Profile: profile,
|
|
AgentsDir: rs.Cfg.AgentsDir,
|
|
SessionsDir: rs.Cfg.SessionsDir,
|
|
SessionID: sessID,
|
|
AgentID: uname,
|
|
CWD: cwd,
|
|
Remote: remoteTarget,
|
|
Runtime: rt,
|
|
NewToolServer: infra.NewToolServer,
|
|
PromptEnvExtra: infra.PromptEnv,
|
|
BaseLayers: layers.BaseLayers(),
|
|
Log: rs.Cfg.Sink.NewLogger("core"),
|
|
Output: NewEventHandler(al),
|
|
ReadPlanStep: func() string {
|
|
return ""
|
|
},
|
|
})
|
|
sess.mu.Lock()
|
|
sess.Core = core
|
|
sess.uname = uname
|
|
sess.setAgentLogLocked(uname, al)
|
|
sess.proc = infra.Proc
|
|
sess.mu.Unlock()
|
|
} else {
|
|
rt.Backend = be
|
|
saveFn, flushFn := sess.Core.SaveFuncs()
|
|
ag := agent.NewAgent(agent.AgentCfg{
|
|
History: nil,
|
|
Runtime: rt,
|
|
Profile: profile,
|
|
AgentsDir: rs.Cfg.AgentsDir,
|
|
AgentID: uname,
|
|
CWD: cwd,
|
|
BaseLayers: layers.BaseLayers(),
|
|
PromptEnvExtra: infra.PromptEnv,
|
|
NewToolServer: infra.NewToolServer,
|
|
NewBackend: backend.NewWithName,
|
|
Output: NewEventHandler(al),
|
|
Log: rs.Cfg.Sink.NewLogger("core"),
|
|
AuditLog: rs.Cfg.Sink.NewLogger("core").Sub("audit"),
|
|
SessionID: sessID,
|
|
ReadPlanStep: func() string {
|
|
return ""
|
|
},
|
|
SaveSession: saveFn,
|
|
FlushSave: flushFn,
|
|
})
|
|
sess.Core.AddAgent(ag)
|
|
// Wire up state change events - capture sessID for hierarchical topic
|
|
sid := sessID
|
|
ag.SetOnStateChange(func(agentID, state string) {
|
|
PublishEvent("session."+sid+".agent."+agentID+".state", state)
|
|
})
|
|
sess.mu.Lock()
|
|
sess.setAgentLogLocked(uname, al)
|
|
sess.mu.Unlock()
|
|
}
|
|
|
|
rs.Mu.Lock()
|
|
if _, exists := rs.Sessions[sessName]; !exists {
|
|
rs.Sessions[sessName] = sess
|
|
}
|
|
rs.Mu.Unlock()
|
|
|
|
ag := sess.Core.FindAgent(uname)
|
|
ag.Submit(sess.SessionCtx, "/agent "+profile)
|
|
if name != "" {
|
|
ag.SetName(name)
|
|
}
|
|
|
|
rs.Cfg.Log.Info("created agent in session %s (backend=%s model=%s agent=%s)",
|
|
sessName, ag.BackendName(), ag.ModelName(), ag.Name())
|
|
PublishEvent("session."+sessID+".agent."+ag.ID()+".new", ag.Name())
|
|
return nil
|
|
}
|