328 lines
11 KiB
Go
328 lines
11 KiB
Go
package sessionmanager
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
var ctx = context.Background()
|
|
|
|
type fakeStore struct {
|
|
sessions map[domain.SessionID]domain.SessionRecord
|
|
pr map[domain.SessionID]domain.PRFacts
|
|
num int
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
|
|
}
|
|
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
|
|
f.num++
|
|
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
|
|
f.sessions[rec.ID] = rec
|
|
return rec, nil
|
|
}
|
|
func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error {
|
|
f.sessions[rec.ID] = rec
|
|
return nil
|
|
}
|
|
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
|
|
r, ok := f.sessions[id]
|
|
return r, ok, nil
|
|
}
|
|
func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
|
|
var out []domain.SessionRecord
|
|
for _, r := range f.sessions {
|
|
if r.ProjectID == p {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeStore) ListAllSessions(context.Context) ([]domain.SessionRecord, error) {
|
|
var out []domain.SessionRecord
|
|
for _, r := range f.sessions {
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
|
|
if pr := f.pr[id]; pr.URL != "" {
|
|
return pr, true, nil
|
|
}
|
|
return domain.PRFacts{}, false, nil
|
|
}
|
|
|
|
type fakeLCM struct {
|
|
store *fakeStore
|
|
completed int
|
|
}
|
|
|
|
func (l *fakeLCM) MarkSpawned(_ context.Context, id domain.SessionID, metadata domain.SessionMetadata) error {
|
|
l.completed++
|
|
rec := l.store.sessions[id]
|
|
rec.IsTerminated = false
|
|
rec.Activity = domain.Activity{State: domain.ActivityIdle, LastActivityAt: time.Now()}
|
|
rec.Metadata = metadata
|
|
l.store.sessions[id] = rec
|
|
return nil
|
|
}
|
|
func (l *fakeLCM) MarkTerminated(_ context.Context, id domain.SessionID) error {
|
|
rec := l.store.sessions[id]
|
|
rec.IsTerminated = true
|
|
rec.Activity = domain.Activity{State: domain.ActivityExited, LastActivityAt: time.Now()}
|
|
l.store.sessions[id] = rec
|
|
return nil
|
|
}
|
|
|
|
type fakeRuntime struct {
|
|
createErr error
|
|
created, destroyed int
|
|
}
|
|
|
|
func (r *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) {
|
|
if r.createErr != nil {
|
|
return ports.RuntimeHandle{}, r.createErr
|
|
}
|
|
r.created++
|
|
return ports.RuntimeHandle{ID: "h1"}, nil
|
|
}
|
|
func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.destroyed++; return nil }
|
|
|
|
type fakeAgent struct{}
|
|
|
|
func (fakeAgent) GetConfigSpec(context.Context) (ports.ConfigSpec, error) {
|
|
return ports.ConfigSpec{}, nil
|
|
}
|
|
func (fakeAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) {
|
|
return []string{"launch"}, nil
|
|
}
|
|
func (fakeAgent) GetPromptDeliveryStrategy(context.Context, ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
|
|
return ports.PromptDeliveryInCommand, nil
|
|
}
|
|
func (fakeAgent) GetAgentHooks(context.Context, ports.WorkspaceHookConfig) error { return nil }
|
|
func (fakeAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
|
|
if id := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]; id != "" {
|
|
return []string{"resume", id}, true, nil
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
func (fakeAgent) SessionInfo(context.Context, ports.SessionRef) (ports.SessionInfo, bool, error) {
|
|
return ports.SessionInfo{}, false, nil
|
|
}
|
|
|
|
// fakeAgents resolves every harness to the same fakeAgent.
|
|
type fakeAgents struct{}
|
|
|
|
func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAgent{}, true }
|
|
|
|
type fakeWorkspace struct {
|
|
destroyErr error
|
|
destroyed int
|
|
}
|
|
|
|
func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
|
|
return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
|
|
}
|
|
func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error {
|
|
w.destroyed++
|
|
return w.destroyErr
|
|
}
|
|
func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
|
|
return w.Create(ctx, cfg)
|
|
}
|
|
|
|
type fakeMessenger struct{ msgs []string }
|
|
|
|
func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
|
|
m.msgs = append(m.msgs, msg)
|
|
return nil
|
|
}
|
|
|
|
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}})
|
|
return m, st, rt, ws
|
|
}
|
|
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {
|
|
st.sessions[id] = domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: meta, IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
|
|
}
|
|
func mkLive(id domain.SessionID) domain.SessionRecord {
|
|
return domain.SessionRecord{ID: id, ProjectID: "mer", Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1"}, Activity: domain.Activity{State: domain.ActivityActive}}
|
|
}
|
|
|
|
func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) {
|
|
m, st, rt, _ := newManager()
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.ID != "mer-1" {
|
|
t.Fatalf("got %q", s.ID)
|
|
}
|
|
if s.Activity.State != domain.ActivityIdle {
|
|
t.Fatalf("fresh session records idle, got %q", s.Activity.State)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatal("runtime not created")
|
|
}
|
|
if st.sessions["mer-1"].Metadata.RuntimeHandleID != "h1" {
|
|
t.Fatal("handle not folded")
|
|
}
|
|
}
|
|
func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
m.runtime = &fakeRuntime{createErr: errors.New("boom")}
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil {
|
|
t.Fatal("expected failure")
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("workspace should roll back")
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("orphaned spawn should be terminated")
|
|
}
|
|
}
|
|
func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) {
|
|
m, st, rt, ws := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
freed, err := m.Kill(ctx, "mer-1")
|
|
if err != nil || !freed {
|
|
t.Fatalf("freed=%v err=%v", freed, err)
|
|
}
|
|
if rt.destroyed != 1 || ws.destroyed != 1 {
|
|
t.Fatal("kill should destroy runtime and workspace")
|
|
}
|
|
}
|
|
func TestKill_RefusesIncompleteHandle(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
|
|
if _, err := m.Kill(ctx, "mer-1"); !errors.Is(err, ErrIncompleteHandle) {
|
|
t.Fatalf("want ErrIncompleteHandle, got %v", err)
|
|
}
|
|
}
|
|
func TestRestore_ReopensTerminal(t *testing.T) {
|
|
m, st, rt, _ := newManager()
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
|
s, err := m.Restore(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.Activity.State != domain.ActivityIdle {
|
|
t.Fatalf("restored records idle, got %q", s.Activity.State)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatal("restore should relaunch")
|
|
}
|
|
}
|
|
func TestRestore_RefusesLiveSession(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
if _, err := m.Restore(ctx, "mer-1"); !errors.Is(err, ErrNotRestorable) {
|
|
t.Fatalf("want ErrNotRestorable, got %v", err)
|
|
}
|
|
}
|
|
func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"})
|
|
st.sessions["mer-2"] = mkLive("mer-2")
|
|
cleaned, err := m.Cleanup(ctx, "mer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(cleaned) != 1 || cleaned[0] != "mer-1" {
|
|
t.Fatalf("got %v", cleaned)
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("live workspace must not be destroyed")
|
|
}
|
|
}
|
|
|
|
func TestSpawn_DefaultsBranchFromSessionID(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// An empty SpawnConfig.Branch defaults to a unique per-session branch.
|
|
if got := st.sessions[s.ID].Metadata.Branch; got != "ao/mer-1" {
|
|
t.Fatalf("default branch = %q, want ao/mer-1", got)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.num = 1
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
|
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prompt := st.sessions[s.ID].Metadata.Prompt
|
|
for _, want := range []string{
|
|
"do it",
|
|
"## Orchestrator coordination",
|
|
`ao send --session mer-1 --message "<your message>"`,
|
|
"Only ping the orchestrator for true blockers, cross-session coordination",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("prompt missing %q:\n%s", want, prompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.num = 1
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
|
|
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prompt := st.sessions[s.ID].Metadata.Prompt
|
|
if strings.Contains(prompt, "## Orchestrator coordination") || strings.Contains(prompt, "ao send --session mer-1") {
|
|
t.Fatalf("terminated orchestrator should not be added to prompt:\n%s", prompt)
|
|
}
|
|
}
|
|
|
|
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prompt := st.sessions[s.ID].Metadata.Prompt
|
|
for _, want := range []string{
|
|
"You are the human-facing coordinator for project mer",
|
|
`ao spawn --project mer --prompt "<clear worker task>"`,
|
|
"`ao send`",
|
|
"avoid doing implementation yourself unless it is necessary",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("prompt missing %q:\n%s", want, prompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSpawn_KeepsExplicitBranch(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Branch: "feature/x"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := st.sessions[s.ID].Metadata.Branch; got != "feature/x" {
|
|
t.Fatalf("explicit branch = %q, want feature/x", got)
|
|
}
|
|
}
|