2479 lines
96 KiB
Go
2479 lines
96 KiB
Go
package sessionmanager
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"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
|
|
projects map[string]domain.ProjectRecord
|
|
num int
|
|
deleteErr error
|
|
// worktrees maps session ID to its saved worktree rows (shutdown-saved marker).
|
|
worktrees map[domain.SessionID][]domain.SessionWorktreeRecord
|
|
// sharedLog, when non-nil, receives an ordered call entry for each
|
|
// UpsertSessionWorktree invocation so ordering tests can compare across fakes.
|
|
sharedLog *[]string
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{
|
|
sessions: map[domain.SessionID]domain.SessionRecord{},
|
|
pr: map[domain.SessionID]domain.PRFacts{},
|
|
projects: map[string]domain.ProjectRecord{},
|
|
worktrees: map[domain.SessionID][]domain.SessionWorktreeRecord{},
|
|
}
|
|
}
|
|
func (f *fakeStore) GetProject(_ context.Context, id string) (domain.ProjectRecord, bool, error) {
|
|
r, ok := f.projects[id]
|
|
return r, ok, nil
|
|
}
|
|
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) DeleteSession(_ context.Context, id domain.SessionID) (bool, error) {
|
|
if f.deleteErr != nil {
|
|
return false, f.deleteErr
|
|
}
|
|
rec, ok := f.sessions[id]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
// Mirror the sqlite gate: only delete rows still in seed state.
|
|
if rec.IsTerminated || rec.Metadata.WorkspacePath != "" || rec.Metadata.RuntimeHandleID != "" || rec.Metadata.AgentSessionID != "" || rec.Metadata.Prompt != "" {
|
|
return false, nil
|
|
}
|
|
delete(f.sessions, id)
|
|
return true, 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
|
|
}
|
|
func (f *fakeStore) UpsertSessionWorktree(_ context.Context, row domain.SessionWorktreeRecord) error {
|
|
if f.sharedLog != nil {
|
|
*f.sharedLog = append(*f.sharedLog, "UpsertSessionWorktree:"+string(row.SessionID))
|
|
}
|
|
rows := f.worktrees[row.SessionID]
|
|
for i, r := range rows {
|
|
if r.RepoName == row.RepoName {
|
|
rows[i] = row
|
|
f.worktrees[row.SessionID] = rows
|
|
return nil
|
|
}
|
|
}
|
|
f.worktrees[row.SessionID] = append(rows, row)
|
|
return nil
|
|
}
|
|
func (f *fakeStore) ListSessionWorktrees(_ context.Context, id domain.SessionID) ([]domain.SessionWorktreeRecord, error) {
|
|
return f.worktrees[id], nil
|
|
}
|
|
func (f *fakeStore) DeleteSessionWorktrees(_ context.Context, id domain.SessionID) error {
|
|
if f.sharedLog != nil {
|
|
*f.sharedLog = append(*f.sharedLog, "DeleteSessionWorktrees:"+string(id))
|
|
}
|
|
delete(f.worktrees, id)
|
|
return nil
|
|
}
|
|
|
|
type fakeLCM struct {
|
|
store *fakeStore
|
|
completed int
|
|
// terminated counts MarkTerminated calls per session id.
|
|
terminated map[domain.SessionID]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 {
|
|
if l.terminated == nil {
|
|
l.terminated = map[domain.SessionID]int{}
|
|
}
|
|
l.terminated[id]++
|
|
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
|
|
destroyErr error
|
|
created, destroyed int
|
|
lastCfg ports.RuntimeConfig
|
|
// aliveByHandle maps a RuntimeHandle.ID to its liveness; missing = false.
|
|
aliveByHandle map[string]bool
|
|
aliveErr error
|
|
destroyedIDs []string
|
|
}
|
|
|
|
func (r *fakeRuntime) Create(_ context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
|
|
if r.createErr != nil {
|
|
return ports.RuntimeHandle{}, r.createErr
|
|
}
|
|
r.lastCfg = cfg
|
|
r.created++
|
|
return ports.RuntimeHandle{ID: "h1"}, nil
|
|
}
|
|
func (r *fakeRuntime) Destroy(_ context.Context, handle ports.RuntimeHandle) error {
|
|
r.destroyed++
|
|
r.destroyedIDs = append(r.destroyedIDs, handle.ID)
|
|
return r.destroyErr
|
|
}
|
|
func (r *fakeRuntime) IsAlive(_ context.Context, handle ports.RuntimeHandle) (bool, error) {
|
|
if r.aliveErr != nil {
|
|
return false, r.aliveErr
|
|
}
|
|
return r.aliveByHandle[handle.ID], 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
|
|
}
|
|
|
|
type launchArgvAgent struct {
|
|
fakeAgent
|
|
argv []string
|
|
}
|
|
|
|
func (a launchArgvAgent) GetLaunchCommand(context.Context, ports.LaunchConfig) ([]string, error) {
|
|
return a.argv, nil
|
|
}
|
|
|
|
// fakeAgents resolves every harness to the same fakeAgent.
|
|
type fakeAgents struct{}
|
|
|
|
func (fakeAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return fakeAgent{}, true }
|
|
|
|
// recordingAgent captures the LaunchConfig it is handed so a test can assert the
|
|
// session manager resolved and forwarded a project's agent config.
|
|
type recordingAgent struct {
|
|
fakeAgent
|
|
lastConfig ports.AgentConfig
|
|
lastLaunch ports.LaunchConfig
|
|
lastRestore ports.RestoreConfig
|
|
}
|
|
|
|
func (a *recordingAgent) GetLaunchCommand(_ context.Context, cfg ports.LaunchConfig) ([]string, error) {
|
|
a.lastConfig = cfg.Config
|
|
a.lastLaunch = cfg
|
|
return []string{"launch"}, nil
|
|
}
|
|
|
|
func (a *recordingAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
|
|
a.lastConfig = cfg.Config
|
|
a.lastRestore = cfg
|
|
// Mirror real adapters: with no native agent-session id to resume, signal
|
|
// "cannot restore" so the manager falls back to a fresh launch.
|
|
if cfg.Session.Metadata[ports.MetadataKeyAgentSessionID] == "" {
|
|
return nil, false, nil
|
|
}
|
|
return []string{"resume"}, true, nil
|
|
}
|
|
|
|
type singleAgent struct{ agent ports.Agent }
|
|
|
|
func (s singleAgent) Agent(domain.AgentHarness) (ports.Agent, bool) { return s.agent, true }
|
|
|
|
func blockedDataDir(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "data")
|
|
if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func requireNoPromptDir(t *testing.T, dataDir string, id domain.SessionID) {
|
|
t.Helper()
|
|
path := filepath.Join(dataDir, "prompts", string(id))
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Fatalf("prompt dir %s still exists", path)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("stat prompt dir %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// alwaysResumeAgent mimics Claude Code: it pins a deterministic session id, so
|
|
// GetRestoreCommand can resume any session even with no captured agentSessionId
|
|
// and no prompt.
|
|
type alwaysResumeAgent struct{ fakeAgent }
|
|
|
|
func (alwaysResumeAgent) GetRestoreCommand(_ context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
|
|
return []string{"resume", cfg.Session.ID}, true, nil
|
|
}
|
|
|
|
// missingAgents resolves no harness, simulating a typo'd or unregistered agent.
|
|
type missingAgents struct{}
|
|
|
|
func (missingAgents) Agent(domain.AgentHarness) (ports.Agent, bool) { return nil, false }
|
|
|
|
type fakeWorkspace struct {
|
|
createErr error
|
|
destroyErr error
|
|
destroyed int
|
|
lastCfg ports.WorkspaceConfig
|
|
// path, when set, is returned as the workspace path so provisioning tests
|
|
// can point at a real temp directory.
|
|
path string
|
|
// stashRef is returned by StashUncommitted (empty means clean worktree).
|
|
stashRef string
|
|
stashErr error
|
|
applyErr error
|
|
forceDestroyErr error
|
|
// stashCalls counts StashUncommitted invocations.
|
|
stashCalls int
|
|
// calls records the sequence of workspace method calls for ordering assertions.
|
|
calls []string
|
|
// sharedLog, when non-nil, receives entries alongside calls so ordering
|
|
// tests can compare workspace calls against store calls in one sequence.
|
|
sharedLog *[]string
|
|
}
|
|
|
|
func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
|
|
if w.createErr != nil {
|
|
return ports.WorkspaceInfo{}, w.createErr
|
|
}
|
|
w.lastCfg = cfg
|
|
path := w.path
|
|
if path == "" {
|
|
path = "/ws/" + string(cfg.SessionID)
|
|
}
|
|
return ports.WorkspaceInfo{Path: path, 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)
|
|
}
|
|
func (w *fakeWorkspace) ForceDestroy(_ context.Context, info ports.WorkspaceInfo) error {
|
|
entry := "ForceDestroy:" + string(info.SessionID)
|
|
w.calls = append(w.calls, entry)
|
|
if w.sharedLog != nil {
|
|
*w.sharedLog = append(*w.sharedLog, entry)
|
|
}
|
|
return w.forceDestroyErr
|
|
}
|
|
func (w *fakeWorkspace) StashUncommitted(_ context.Context, info ports.WorkspaceInfo) (string, error) {
|
|
w.stashCalls++
|
|
entry := "StashUncommitted:" + string(info.SessionID)
|
|
w.calls = append(w.calls, entry)
|
|
if w.sharedLog != nil {
|
|
*w.sharedLog = append(*w.sharedLog, entry)
|
|
}
|
|
return w.stashRef, w.stashErr
|
|
}
|
|
func (w *fakeWorkspace) ApplyPreserved(_ context.Context, info ports.WorkspaceInfo, ref string) error {
|
|
w.calls = append(w.calls, "ApplyPreserved:"+string(info.SessionID))
|
|
return w.applyErr
|
|
}
|
|
|
|
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()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
// Stub lookPath so the pre-launch agent-binary check passes; the fakeAgent
|
|
// returns argv ["launch"] which is not a real binary on PATH.
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
return m, st, rt, ws
|
|
}
|
|
func testRoleAgents() domain.ProjectConfig {
|
|
return domain.ProjectConfig{
|
|
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
|
Orchestrator: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
|
}
|
|
}
|
|
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_ResolvesProjectConfig(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
|
DefaultBranch: "develop",
|
|
Env: map[string]string{"FOO": "bar"},
|
|
AgentConfig: domain.AgentConfig{Model: "base-model"},
|
|
// A worker role override wins over the base agent config for workers.
|
|
Worker: domain.RoleOverride{Harness: domain.HarnessCodex, AgentConfig: domain.AgentConfig{Model: "worker-model"}},
|
|
}}
|
|
agent := &recordingAgent{}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
rec, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if agent.lastConfig.Model != "worker-model" {
|
|
t.Fatalf("launch model = %q, want role override worker-model", agent.lastConfig.Model)
|
|
}
|
|
if rec.Harness != domain.HarnessCodex {
|
|
t.Fatalf("harness = %q, want codex from role override", rec.Harness)
|
|
}
|
|
if ws.lastCfg.BaseBranch != "develop" {
|
|
t.Fatalf("workspace base branch = %q, want develop", ws.lastCfg.BaseBranch)
|
|
}
|
|
if rt.lastCfg.Env["FOO"] != "bar" {
|
|
t.Fatalf("runtime env FOO = %q, want bar", rt.lastCfg.Env["FOO"])
|
|
}
|
|
if rt.lastCfg.Env[EnvSessionID] == "" {
|
|
t.Fatal("runtime env missing AO_SESSION_ID")
|
|
}
|
|
|
|
// A project with no stored config yields a zero AgentConfig (adapter defaults)
|
|
// when the spawn explicitly names its agent.
|
|
st.projects["bare"] = domain.ProjectRecord{ID: "bare"}
|
|
agent.lastConfig = ports.AgentConfig{Model: "stale"}
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "bare", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !agent.lastConfig.IsZero() {
|
|
t.Fatalf("launch config = %#v, want zero for project without config", agent.lastConfig)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_RejectsMissingRoleHarness(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
|
|
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
|
LookPath: func(string) (string, error) { return "/bin/true", nil },
|
|
})
|
|
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ErrMissingHarness) {
|
|
t.Fatalf("worker err = %v, want ErrMissingHarness", err)
|
|
}
|
|
if len(st.sessions) != 0 {
|
|
t.Fatalf("missing worker harness must not create a session row, got %d", len(st.sessions))
|
|
}
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); !errors.Is(err, ErrMissingHarness) {
|
|
t.Fatalf("orchestrator err = %v, want ErrMissingHarness", err)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_ExplicitHarnessWinsWithoutProjectRoleHarness(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer"}
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{}, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
|
|
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
|
LookPath: func(string) (string, error) { return "/bin/true", nil },
|
|
})
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessCodex}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := st.sessions["mer-1"].Harness; got != domain.HarnessCodex {
|
|
t.Fatalf("explicit harness = %q, want %q", got, domain.HarnessCodex)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_AssignsIDAndGoesIdle(t *testing.T) {
|
|
m, st, rt, _ := newManager()
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, 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")
|
|
}
|
|
}
|
|
|
|
// TestSpawn_StampsUTCTimestamps locks the default clock to UTC so spawn-stamped
|
|
// CreatedAt/UpdatedAt match every other session write (rename, activity), which
|
|
// all use time.Now().UTC(). A local default produced mixed-timezone timestamps
|
|
// in `ao session get` (created in local time, updated in UTC).
|
|
func TestSpawn_StampsUTCTimestamps(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := st.sessions["mer-1"]
|
|
if loc := rec.CreatedAt.Location(); loc != time.UTC {
|
|
t.Fatalf("CreatedAt location = %v, want UTC", loc)
|
|
}
|
|
if loc := rec.UpdatedAt.Location(); loc != time.UTC {
|
|
t.Fatalf("UpdatedAt location = %v, want UTC", loc)
|
|
}
|
|
}
|
|
|
|
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 rec, present := st.sessions["mer-1"]; present {
|
|
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
|
|
}
|
|
}
|
|
|
|
// TestSpawn_DeletesSeedRowOnWorkspaceFailure covers the failed-spawn cleanup:
|
|
// when workspace materialization fails (e.g. gitworktree refuses a branch
|
|
// checked out elsewhere), nothing observable was built, so the seed row is
|
|
// deleted outright rather than parked as a terminated orphan that clutters
|
|
// session lists.
|
|
func TestSpawn_DeletesSeedRowOnWorkspaceFailure(t *testing.T) {
|
|
m, st, rt, ws := newManager()
|
|
ws.createErr = ports.ErrWorkspaceBranchCheckedOutElsewhere
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if !errors.Is(err, ports.ErrWorkspaceBranchCheckedOutElsewhere) {
|
|
t.Fatalf("err = %v, want ports.ErrWorkspaceBranchCheckedOutElsewhere", err)
|
|
}
|
|
if rec, present := st.sessions["mer-1"]; present {
|
|
t.Fatalf("seed row must be deleted, got %+v", rec)
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatal("runtime.Create must not run when workspace materialization fails")
|
|
}
|
|
}
|
|
|
|
// TestSpawn_ParksRowTerminatedWhenSeedDeleteFails asserts the fallback: if the
|
|
// seed-row delete itself fails, the failed spawn still parks the row as
|
|
// terminated so it never looks live.
|
|
func TestSpawn_ParksRowTerminatedWhenSeedDeleteFails(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
ws.createErr = ports.ErrWorkspaceBranchNotFetched
|
|
st.deleteErr = errors.New("db locked")
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); !errors.Is(err, ports.ErrWorkspaceBranchNotFetched) {
|
|
t.Fatalf("err = %v, want ports.ErrWorkspaceBranchNotFetched", err)
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("row must fall back to terminated when the seed delete fails")
|
|
}
|
|
}
|
|
func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) {
|
|
m, st, rt, ws := newManager()
|
|
dataDir := t.TempDir()
|
|
m.dataDir = dataDir
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
if _, err := m.writeSystemPromptFile("mer-1", "system prompt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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")
|
|
}
|
|
requireNoPromptDir(t, dataDir, "mer-1")
|
|
}
|
|
|
|
// TestKill_TerminatesIncompleteHandle: a session whose runtime handle or
|
|
// workspace path is missing is still terminated — the destroy steps are
|
|
// skipped, but the session moves to terminal state so it can be cleaned up
|
|
// from the dashboard.
|
|
func TestKill_TerminatesIncompleteHandle(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
|
|
freed, err := m.Kill(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatalf("want nil error, got %v", err)
|
|
}
|
|
if freed {
|
|
t.Fatal("freed = true, want false for session with no workspace")
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("session should be terminated even without a handle")
|
|
}
|
|
}
|
|
|
|
// TestKill_DirtyWorkspaceTerminatesAndPreserves: a workspace teardown refused
|
|
// because of uncommitted work must NOT fail the kill — the session terminates,
|
|
// the runtime is gone, and freed=false reports the preserved worktree. This is
|
|
// the normal path for any session with in-progress changes, so an error here
|
|
// would turn every such kill into a 500.
|
|
func TestKill_DirtyWorkspaceTerminatesAndPreserves(t *testing.T) {
|
|
m, st, rt, ws := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
ws.destroyErr = fmt.Errorf("gitworktree: refusing to remove: %w", ports.ErrWorkspaceDirty)
|
|
freed, err := m.Kill(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatalf("kill dirty workspace err = %v, want nil", err)
|
|
}
|
|
if freed {
|
|
t.Fatal("freed = true, want false for preserved workspace")
|
|
}
|
|
if rt.destroyed != 1 {
|
|
t.Fatal("runtime should be destroyed")
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("session should be terminated")
|
|
}
|
|
}
|
|
|
|
func TestKill_DeletesStaleRestoreMarker(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{
|
|
{SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/tmp/wt"},
|
|
}
|
|
|
|
freed, err := m.Kill(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatalf("Kill: %v", err)
|
|
}
|
|
if !freed {
|
|
t.Fatal("Kill freed = false, want true")
|
|
}
|
|
if rows := st.worktrees["mer-1"]; len(rows) != 0 {
|
|
t.Fatalf("stale restore marker = %+v, want deleted", rows)
|
|
}
|
|
}
|
|
|
|
// TestKill_OtherWorkspaceErrorStillFails: only the typed dirty refusal is a
|
|
// success-with-preserved-workspace; any other teardown failure keeps erroring.
|
|
func TestKill_OtherWorkspaceErrorStillFails(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
ws.destroyErr = errors.New("disk on fire")
|
|
if _, err := m.Kill(ctx, "mer-1"); err == nil || !strings.Contains(err.Error(), "disk on fire") {
|
|
t.Fatalf("kill err = %v, want workspace error surfaced", 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_AppliesProjectAgentConfig(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{AgentConfig: domain.AgentConfig{Model: "restore-model"}}}
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if agent.lastConfig.Model != "restore-model" {
|
|
t.Fatalf("restore config model = %q, want restore-model (config must carry across restore)", agent.lastConfig.Model)
|
|
}
|
|
}
|
|
|
|
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")
|
|
res, err := m.Cleanup(ctx, "mer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Cleaned) != 1 || res.Cleaned[0] != "mer-1" {
|
|
t.Fatalf("got %v", res.Cleaned)
|
|
}
|
|
if len(res.Skipped) != 0 {
|
|
t.Fatalf("skipped = %v, want none", res.Skipped)
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("live workspace must not be destroyed")
|
|
}
|
|
}
|
|
|
|
// TestCleanup_ReportsSkippedWorkspaces: a refused teardown must be visible in
|
|
// the result with a reason — a silent skip leaves users staring at
|
|
// "Would clean N … 0 sessions cleaned" with no explanation.
|
|
func TestCleanup_ReportsSkippedWorkspaces(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"})
|
|
ws.destroyErr = fmt.Errorf("gitworktree: refusing to remove: %w", ports.ErrWorkspaceDirty)
|
|
res, err := m.Cleanup(ctx, "mer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Cleaned) != 0 {
|
|
t.Fatalf("cleaned = %v, want none", res.Cleaned)
|
|
}
|
|
if len(res.Skipped) != 1 || res.Skipped[0].SessionID != "mer-1" {
|
|
t.Fatalf("skipped = %v, want mer-1", res.Skipped)
|
|
}
|
|
if res.Skipped[0].Reason != "workspace has uncommitted changes" {
|
|
t.Fatalf("reason = %q", res.Skipped[0].Reason)
|
|
}
|
|
|
|
// A non-dirty teardown failure is reported too — but with a fixed public
|
|
// reason: the raw cause carries internal filesystem paths and belongs in
|
|
// the server log, not the API response.
|
|
ws.destroyErr = errors.New("disk on fire")
|
|
res, err = m.Cleanup(ctx, "mer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Skipped) != 1 || res.Skipped[0].Reason != "workspace teardown failed" {
|
|
t.Fatalf("skipped = %v, want fixed teardown-failed reason", res.Skipped)
|
|
}
|
|
if strings.Contains(res.Skipped[0].Reason, "disk on fire") {
|
|
t.Fatalf("raw internal error leaked into public reason: %q", res.Skipped[0].Reason)
|
|
}
|
|
}
|
|
|
|
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 root branch
|
|
// under a namespace that can also hold sibling PR branches.
|
|
if got := st.sessions[s.ID].Metadata.Branch; got != "ao/mer-1/root" {
|
|
t.Fatalf("default branch = %q, want ao/mer-1/root", got)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
|
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeAuto},
|
|
Worker: domain.RoleOverride{
|
|
Harness: domain.HarnessClaudeCode,
|
|
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions},
|
|
},
|
|
}}
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if agent.lastLaunch.Config.Permissions != domain.PermissionModeBypassPermissions {
|
|
t.Fatalf("launch config permissions = %q, want bypass", agent.lastLaunch.Config.Permissions)
|
|
}
|
|
if agent.lastLaunch.Permissions != domain.PermissionModeBypassPermissions {
|
|
t.Fatalf("launch permissions = %q, want bypass", agent.lastLaunch.Permissions)
|
|
}
|
|
}
|
|
|
|
func TestRestore_ForwardsResolvedAgentConfigPermissions(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
|
AgentConfig: domain.AgentConfig{Permissions: domain.PermissionModeBypassPermissions},
|
|
}}
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{Branch: "ao/mer-1", WorkspacePath: "/tmp/ws", AgentSessionID: "native-1"},
|
|
}
|
|
agent := &recordingAgent{}
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: func(string) (string, error) { return "/bin/true", nil }})
|
|
|
|
_, err := m.Restore(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if agent.lastRestore.Config.Permissions != domain.PermissionModeBypassPermissions {
|
|
t.Fatalf("restore config permissions = %q, want bypass", agent.lastRestore.Config.Permissions)
|
|
}
|
|
if agent.lastRestore.Permissions != domain.PermissionModeBypassPermissions {
|
|
t.Fatalf("restore permissions = %q, want bypass", agent.lastRestore.Permissions)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_AppendsActiveOrchestratorContact(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
st.num = 1
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
|
agent := &recordingAgent{}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The user prompt must be preserved and stored in metadata as-is.
|
|
if got := st.sessions[s.ID].Metadata.Prompt; got != "do it" {
|
|
t.Fatalf("metadata prompt = %q, want %q", got, "do it")
|
|
}
|
|
|
|
// Coordination instructions must be in the system prompt, not the user prompt.
|
|
systemPrompt := agent.lastLaunch.SystemPrompt
|
|
for _, want := range []string{
|
|
"## Orchestrator Coordination",
|
|
`ao send --session mer-1 --message "<your message>"`,
|
|
"Message it only for true blockers, cross-session coordination",
|
|
} {
|
|
if !strings.Contains(systemPrompt, want) {
|
|
t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt)
|
|
}
|
|
}
|
|
if strings.Contains(agent.lastLaunch.Prompt, "## Orchestrator Coordination") {
|
|
t.Fatalf("orchestrator coordination must not be in the user prompt:\n%s", agent.lastLaunch.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_WritesSystemPromptFile(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.num = 1
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
|
agent := &recordingAgent{}
|
|
dataDir := t.TempDir()
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{},
|
|
Agents: singleAgent{agent: agent},
|
|
Workspace: &fakeWorkspace{},
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
DataDir: dataDir,
|
|
LookPath: lookPath,
|
|
})
|
|
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantPath := filepath.Join(dataDir, "prompts", string(s.ID), "system.md")
|
|
if agent.lastLaunch.SystemPromptFile != wantPath {
|
|
t.Fatalf("system prompt file = %q, want %q", agent.lastLaunch.SystemPromptFile, wantPath)
|
|
}
|
|
data, err := os.ReadFile(wantPath)
|
|
if err != nil {
|
|
t.Fatalf("read system prompt file: %v", err)
|
|
}
|
|
wantBody := strings.TrimRight(agent.lastLaunch.SystemPrompt, "\n") + "\n"
|
|
if string(data) != wantBody {
|
|
t.Fatalf("system prompt file body\nwant:\n%s\n got:\n%s", wantBody, string(data))
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_FallsBackToInlineWhenPromptFileUnavailable(t *testing.T) {
|
|
st := newFakeStore()
|
|
agent := &recordingAgent{}
|
|
dataDir := blockedDataDir(t)
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{},
|
|
Agents: singleAgent{agent: agent},
|
|
Workspace: &fakeWorkspace{},
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
DataDir: dataDir,
|
|
LookPath: lookPath,
|
|
Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
|
|
})
|
|
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, Prompt: "do it"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if agent.lastLaunch.SystemPrompt == "" {
|
|
t.Fatal("SystemPrompt is empty, want inline prompt fallback")
|
|
}
|
|
if agent.lastLaunch.SystemPromptFile != "" {
|
|
t.Fatalf("SystemPromptFile = %q, want empty after write failure", agent.lastLaunch.SystemPromptFile)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_PromptFileFailureBlocksFileOnlyHarness(t *testing.T) {
|
|
st := newFakeStore()
|
|
agent := &recordingAgent{}
|
|
dataDir := blockedDataDir(t)
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{},
|
|
Agents: singleAgent{agent: agent},
|
|
Workspace: &fakeWorkspace{},
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
DataDir: dataDir,
|
|
LookPath: lookPath,
|
|
})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessAider, Prompt: "do it"})
|
|
if err == nil {
|
|
t.Fatal("Spawn succeeded, want prompt-file error for file-only harness")
|
|
}
|
|
if !strings.Contains(err.Error(), "system prompt file") {
|
|
t.Fatalf("Spawn err = %v, want system prompt file error", err)
|
|
}
|
|
if _, ok := st.sessions["mer-1"]; ok {
|
|
t.Fatal("seed row still exists after prompt-file failure")
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_IssueWithoutPromptGetsFallbackTaskPrompt(t *testing.T) {
|
|
st := newFakeStore()
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode, IssueID: "2272"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := "Work on issue 2272.\n\nIssue details were not pre-fetched. Start by reading the issue from the tracker, then inspect the relevant code and tests. Implement the smallest appropriate fix, run focused verification, and open or update a PR if this project uses PRs."
|
|
if agent.lastLaunch.Prompt != want {
|
|
t.Fatalf("launch prompt = %q, want %q", agent.lastLaunch.Prompt, want)
|
|
}
|
|
if got := st.sessions[s.ID].Metadata.Prompt; got != want {
|
|
t.Fatalf("metadata prompt = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_ProjectRulesInSystemPrompt(t *testing.T) {
|
|
projectDir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(projectDir, "docs"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(projectDir, "docs", "rules.md"), []byte("File rule.\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := testRoleAgents()
|
|
cfg.AgentRules = "Inline rule."
|
|
cfg.AgentRulesFile = "docs/rules.md"
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Path: projectDir, Config: cfg}
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
systemPrompt := agent.lastLaunch.SystemPrompt
|
|
for _, want := range []string{"## AO Worker Role", "## Project Rules", "Inline rule.", "File rule."} {
|
|
if !strings.Contains(systemPrompt, want) {
|
|
t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt)
|
|
}
|
|
}
|
|
if strings.Contains(agent.lastLaunch.Prompt, "Inline rule.") || strings.Contains(agent.lastLaunch.Prompt, "File rule.") {
|
|
t.Fatalf("project rules must not be in task prompt:\n%s", agent.lastLaunch.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_IssueContextStaysInTaskPrompt(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
IssueID: "2272",
|
|
IssueContext: "Title: Enrich prompts\nBody: Include issue context.",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, want := range []string{"Work on issue 2272.", "## Issue Context", "Title: Enrich prompts", "Fetch comments or linked issues only if you need additional context"} {
|
|
if !strings.Contains(agent.lastLaunch.Prompt, want) {
|
|
t.Fatalf("task prompt missing %q:\n%s", want, agent.lastLaunch.Prompt)
|
|
}
|
|
}
|
|
if strings.Contains(agent.lastLaunch.SystemPrompt, "Title: Enrich prompts") || strings.Contains(agent.lastLaunch.SystemPrompt, "## Issue Context") {
|
|
t.Fatalf("issue context must not be in system prompt:\n%s", agent.lastLaunch.SystemPrompt)
|
|
}
|
|
}
|
|
|
|
func TestSpawnWorker_SkipsTerminatedOrchestratorContact(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
st.num = 1
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true}
|
|
agent := &recordingAgent{}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
systemPrompt := agent.lastLaunch.SystemPrompt
|
|
if strings.Contains(systemPrompt, "## Orchestrator Coordination") || strings.Contains(systemPrompt, "ao send --session mer-1") {
|
|
t.Fatalf("terminated orchestrator should not be added to system prompt:\n%s", systemPrompt)
|
|
}
|
|
}
|
|
|
|
func TestSpawnOrchestrator_UsesCoordinatorPrompt(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
agent := &recordingAgent{}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Coordinator instructions must be in the system prompt, not the user prompt.
|
|
systemPrompt := agent.lastLaunch.SystemPrompt
|
|
for _, want := range []string{
|
|
"You are the human-facing orchestrator for project mer",
|
|
`ao spawn --project mer --name "<label, max 20 chars>" --prompt "<clear worker task>"`,
|
|
"Use `ao send` for session communication",
|
|
"Delegate implementation, fixes, tests, and PR ownership to worker sessions",
|
|
"skills/using-ao/SKILL.md",
|
|
} {
|
|
if !strings.Contains(systemPrompt, want) {
|
|
t.Fatalf("system prompt missing %q:\n%s", want, systemPrompt)
|
|
}
|
|
}
|
|
if strings.Contains(agent.lastLaunch.Prompt, "You are the human-facing orchestrator") {
|
|
t.Fatalf("coordinator role must not be in the user prompt:\n%s", agent.lastLaunch.Prompt)
|
|
}
|
|
|
|
// A promptless orchestrator gets no auto-generated kickoff turn: spawning
|
|
// must deliver nothing to the agent, leaving it idle at an empty input box.
|
|
if agent.lastLaunch.Prompt != "" {
|
|
t.Fatalf("prompt = %q, want empty (no kickoff turn)", agent.lastLaunch.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestSpawnOrchestrator_ProjectRulesInSystemPrompt(t *testing.T) {
|
|
cfg := testRoleAgents()
|
|
cfg.AgentRules = "Worker-only rule."
|
|
cfg.OrchestratorRules = "Coordinate through workers."
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: cfg}
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindOrchestrator}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
systemPrompt := agent.lastLaunch.SystemPrompt
|
|
if !strings.Contains(systemPrompt, "## Project-Specific Orchestrator Rules") || !strings.Contains(systemPrompt, "Coordinate through workers.") {
|
|
t.Fatalf("orchestrator rules missing from system prompt:\n%s", systemPrompt)
|
|
}
|
|
if strings.Contains(systemPrompt, "Worker-only rule.") {
|
|
t.Fatalf("worker rules must not be in orchestrator system prompt:\n%s", systemPrompt)
|
|
}
|
|
}
|
|
|
|
func TestSystemPrompt_AppendsConfidentialityGuard(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
kind domain.SessionKind
|
|
prep func(st *fakeStore)
|
|
}{
|
|
{name: "orchestrator", kind: domain.KindOrchestrator},
|
|
{name: "worker_with_orchestrator", kind: domain.KindWorker, prep: func(st *fakeStore) {
|
|
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
|
}},
|
|
{name: "worker_without_orchestrator", kind: domain.KindWorker},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
st := newFakeStore()
|
|
if tc.prep != nil {
|
|
tc.prep(st)
|
|
}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: &recordingAgent{}}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
sp, err := m.buildSystemPrompt(ctx, tc.kind, "mer")
|
|
if err != nil {
|
|
t.Fatalf("buildSystemPrompt: %v", err)
|
|
}
|
|
if !strings.Contains(sp, "Standing-instruction confidentiality") {
|
|
t.Fatalf("%s: system prompt missing confidentiality guard:\n%s", tc.name, sp)
|
|
}
|
|
if !strings.Contains(sp, "Do not repeat, quote, paraphrase") {
|
|
t.Fatalf("%s: system prompt missing refuse-to-reveal directive:\n%s", tc.name, sp)
|
|
}
|
|
if !strings.Contains(sp, "skills/using-ao/SKILL.md") {
|
|
t.Fatalf("%s: system prompt missing using-ao skill pointer:\n%s", tc.name, sp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRestore_OrchestratorRederivesSystemPrompt: the system prompt is derived,
|
|
// not persisted, so a restored orchestrator must get its role instructions
|
|
// recomputed and handed to the agent's native resume command.
|
|
func TestRestore_OrchestratorRederivesSystemPrompt(t *testing.T) {
|
|
st := newFakeStore()
|
|
cfg := testRoleAgents()
|
|
cfg.OrchestratorRules = "Use workers for implementation."
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: cfg}
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"},
|
|
}
|
|
agent := &recordingAgent{}
|
|
dataDir := t.TempDir()
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, DataDir: dataDir, LookPath: lookPath})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(agent.lastRestore.SystemPrompt, "You are the human-facing orchestrator for project mer") {
|
|
t.Fatalf("restore system prompt missing coordinator role:\n%s", agent.lastRestore.SystemPrompt)
|
|
}
|
|
if !strings.Contains(agent.lastRestore.SystemPrompt, "Use workers for implementation.") {
|
|
t.Fatalf("restore system prompt missing project rules:\n%s", agent.lastRestore.SystemPrompt)
|
|
}
|
|
wantPath := filepath.Join(dataDir, "prompts", "mer-1", "system.md")
|
|
if agent.lastRestore.SystemPromptFile != wantPath {
|
|
t.Fatalf("restore system prompt file = %q, want %q", agent.lastRestore.SystemPromptFile, wantPath)
|
|
}
|
|
}
|
|
|
|
func TestRestore_FallsBackToInlineWhenPromptFileUnavailable(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, Harness: domain.HarnessClaudeCode, IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"},
|
|
}
|
|
agent := &recordingAgent{}
|
|
dataDir := blockedDataDir(t)
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{},
|
|
Agents: singleAgent{agent: agent},
|
|
Workspace: &fakeWorkspace{},
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
DataDir: dataDir,
|
|
LookPath: lookPath,
|
|
Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)),
|
|
})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if agent.lastRestore.SystemPrompt == "" {
|
|
t.Fatal("SystemPrompt is empty, want inline prompt fallback")
|
|
}
|
|
if agent.lastRestore.SystemPromptFile != "" {
|
|
t.Fatalf("SystemPromptFile = %q, want empty after write failure", agent.lastRestore.SystemPromptFile)
|
|
}
|
|
}
|
|
|
|
func TestRestore_PromptFileFailureBlocksFileOnlyHarness(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessAider, IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x", Prompt: "do it"},
|
|
}
|
|
agent := &recordingAgent{}
|
|
dataDir := blockedDataDir(t)
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: &fakeRuntime{},
|
|
Agents: singleAgent{agent: agent},
|
|
Workspace: &fakeWorkspace{},
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
DataDir: dataDir,
|
|
LookPath: lookPath,
|
|
})
|
|
|
|
_, err := m.Restore(ctx, "mer-1")
|
|
if err == nil {
|
|
t.Fatal("Restore succeeded, want prompt-file error for file-only harness")
|
|
}
|
|
if !strings.Contains(err.Error(), "system prompt file") {
|
|
t.Fatalf("Restore err = %v, want system prompt file error", err)
|
|
}
|
|
}
|
|
|
|
// TestRestore_FallbackLaunchCarriesSystemPrompt: when the agent has no native
|
|
// session to resume, the fresh-launch fallback must carry the re-derived
|
|
// system prompt alongside the persisted task prompt.
|
|
func TestRestore_FallbackLaunchCarriesSystemPrompt(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", Prompt: "kick off"},
|
|
}
|
|
agent := &recordingAgent{}
|
|
dataDir := t.TempDir()
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, DataDir: dataDir, LookPath: lookPath})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(agent.lastLaunch.SystemPrompt, "You are the human-facing orchestrator for project mer") {
|
|
t.Fatalf("fallback launch system prompt missing coordinator role:\n%s", agent.lastLaunch.SystemPrompt)
|
|
}
|
|
wantPath := filepath.Join(dataDir, "prompts", "mer-1", "system.md")
|
|
if agent.lastLaunch.SystemPromptFile != wantPath {
|
|
t.Fatalf("fallback launch system prompt file = %q, want %q", agent.lastLaunch.SystemPromptFile, wantPath)
|
|
}
|
|
if agent.lastLaunch.Prompt != "kick off" {
|
|
t.Fatalf("fallback launch prompt = %q, want persisted task prompt", agent.lastLaunch.Prompt)
|
|
}
|
|
}
|
|
|
|
// TestRestore_PromptlessOrchestratorResumesViaAdapter locks the orchestrator
|
|
// fix: a promptless session with no captured agentSessionId is still restorable
|
|
// when the adapter can resume it (Claude pins a deterministic --session-id).
|
|
// Before the fix the metadata-only guard rejected it with ErrNotResumable, so
|
|
// every boot abandoned the orchestrator and spawned a fresh one.
|
|
func TestRestore_PromptlessOrchestratorResumesViaAdapter(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
|
|
// No AgentSessionID, no Prompt: exactly how orchestrators are persisted.
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-orchestrator"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
rt := &fakeRuntime{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: alwaysResumeAgent{}}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatalf("promptless orchestrator must restore via adapter resume, got err = %v", err)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("runtime.Create = %d, want 1 (resumed)", rt.created)
|
|
}
|
|
if st.sessions["mer-1"].IsTerminated {
|
|
t.Error("orchestrator must be live after restore")
|
|
}
|
|
}
|
|
|
|
// TestRestore_PromptlessUnresumableRelaunchesFresh covers the genuine-reboot
|
|
// case: a promptless session whose adapter cannot resume (no native session id,
|
|
// no captured AgentSessionID) must be relaunched fresh via GetLaunchCommand
|
|
// in the SAME id. The orchestrator is the canonical example: after a reboot
|
|
// where tmux is truly gone, RestoreAll must recover it in place rather than
|
|
// abandon it and mint a new one (which caused the id-increment bug).
|
|
func TestRestore_PromptlessUnresumableRelaunchesFresh(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, IsTerminated: true,
|
|
// No AgentSessionID, no Prompt: exactly how an orchestrator is persisted.
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-orchestrator"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
rt := &fakeRuntime{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
// fakeAgents resolves to fakeAgent, whose GetRestoreCommand returns ok=false
|
|
// without an agentSessionId, and GetLaunchCommand returns a valid argv.
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatalf("promptless unresumable session must relaunch fresh, got err = %v", err)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("runtime.Create = %d, want 1 (fresh launch)", rt.created)
|
|
}
|
|
if st.sessions["mer-1"].IsTerminated {
|
|
t.Error("session must be live after fresh relaunch")
|
|
}
|
|
}
|
|
|
|
// TestRestore_PromptlessWorkerNotResumable is the RED test for the promptless-worker
|
|
// fix: a KindWorker session with no prompt and no captured AgentSessionID (so the
|
|
// adapter returns ok=false) must NOT be blank-relaunched. The session had no task
|
|
// to replay and no native id to resume from, so relaunching fresh would silently
|
|
// drop its work. Restore must return ErrNotResumable and leave the session terminated
|
|
// (runtime.Create must NOT be called).
|
|
func TestRestore_PromptlessWorkerNotResumable(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, IsTerminated: true,
|
|
// No AgentSessionID, no Prompt: promptless worker with no resume handle.
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
rt := &fakeRuntime{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
// fakeAgents resolves to fakeAgent, whose GetRestoreCommand returns ok=false
|
|
// when there is no AgentSessionID. With a KindWorker and empty Prompt, this
|
|
// must produce ErrNotResumable instead of a blank relaunch.
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Restore(ctx, "mer-1")
|
|
if !errors.Is(err, ErrNotResumable) {
|
|
t.Fatalf("promptless unresumable worker must return ErrNotResumable, got %v", err)
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatalf("runtime.Create = %d, want 0 (must not relaunch a promptless worker)", rt.created)
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Error("session must remain terminated after ErrNotResumable")
|
|
}
|
|
}
|
|
|
|
// TestRestore_WorkerPointsAtCurrentOrchestrator: a restored worker's
|
|
// coordination hint must reference the orchestrator active at restore time,
|
|
// not the one from its original spawn.
|
|
func TestRestore_WorkerPointsAtCurrentOrchestrator(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.sessions["mer-9"] = domain.SessionRecord{ID: "mer-9", ProjectID: "mer", Kind: domain.KindOrchestrator}
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker, IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"},
|
|
}
|
|
agent := &recordingAgent{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: &fakeRuntime{}, Agents: singleAgent{agent: agent}, Workspace: &fakeWorkspace{}, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(agent.lastRestore.SystemPrompt, `ao send --session mer-9`) {
|
|
t.Fatalf("restore system prompt missing current orchestrator contact:\n%s", agent.lastRestore.SystemPrompt)
|
|
}
|
|
}
|
|
|
|
// TestRestore_RefusesIncompleteHandle covers Bug 2: a terminated row whose
|
|
// spawn failed before the workspace landed (no WorkspacePath, no Branch) must
|
|
// fail Restore with ErrIncompleteHandle — the same typed sentinel Kill returns
|
|
// for the same shape — so the HTTP layer surfaces a typed 409 instead of an
|
|
// opaque 500.
|
|
func TestRestore_RefusesIncompleteHandle(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
// Seed a terminated row with no workspace and no branch (the post-failure
|
|
// shape of a Spawn that died before workspace.Create succeeded).
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{Prompt: "do it"},
|
|
}
|
|
if _, err := m.Restore(ctx, "mer-1"); !errors.Is(err, ErrIncompleteHandle) {
|
|
t.Fatalf("want ErrIncompleteHandle, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRollbackSpawn_DeletesSeedRow covers Bug 4: a session row in seed state
|
|
// (no workspace, no runtime, no agent session id, not terminated) is deleted
|
|
// outright by RollbackSpawn so the user never sees an orphan terminated row.
|
|
func TestRollbackSpawn_DeletesSeedRow(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
dataDir := t.TempDir()
|
|
m.dataDir = dataDir
|
|
// Seed row matches what CreateSession produces — no Metadata at all.
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Activity: domain.Activity{State: domain.ActivityIdle},
|
|
}
|
|
if _, err := m.writeSystemPromptFile("mer-1", "system prompt"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deleted, killed, err := m.RollbackSpawn(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatalf("rollback err = %v", err)
|
|
}
|
|
if !deleted || killed {
|
|
t.Fatalf("deleted=%v killed=%v, want deleted=true killed=false", deleted, killed)
|
|
}
|
|
if _, present := st.sessions["mer-1"]; present {
|
|
t.Fatal("seed row must be removed from the store, not left as terminated")
|
|
}
|
|
requireNoPromptDir(t, dataDir, "mer-1")
|
|
}
|
|
|
|
// TestRollbackSpawn_FallsBackToKillForLiveRow asserts the no-resurrection
|
|
// guarantee from Bug 4's RCA: once a row has observable spawn output (workspace
|
|
// + runtime handle), DeleteSession is a no-op and rollback falls back to Kill
|
|
// so the runtime + workspace are torn down rather than abandoned.
|
|
func TestRollbackSpawn_FallsBackToKillForLiveRow(t *testing.T) {
|
|
m, st, rt, ws := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
deleted, killed, err := m.RollbackSpawn(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatalf("rollback err = %v", err)
|
|
}
|
|
if deleted || !killed {
|
|
t.Fatalf("deleted=%v killed=%v, want deleted=false killed=true", deleted, killed)
|
|
}
|
|
if rt.destroyed != 1 || ws.destroyed != 1 {
|
|
t.Fatalf("kill teardown not invoked: rt=%d ws=%d", rt.destroyed, ws.destroyed)
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("live row should be marked terminated after kill-fallback")
|
|
}
|
|
}
|
|
|
|
// TestSpawn_RejectsMissingAgentBinary covers Bug 6: when the agent adapter
|
|
// returns an argv whose binary is not on PATH, Manager.Spawn must abort BEFORE
|
|
// runtime.Create rather than launching into an empty tmux pane that the
|
|
// reaper later mistakes for a live session.
|
|
func TestSpawn_RejectsMissingAgentBinary(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
dataDir := t.TempDir()
|
|
notFound := func(name string) (string, error) {
|
|
if name == "tmux" {
|
|
return "/bin/tmux", nil
|
|
}
|
|
return "", fmt.Errorf("exec: %q: not found", name)
|
|
}
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, DataDir: dataDir, LookPath: notFound})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
|
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatal("runtime.Create must NOT run when the agent binary is missing")
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("workspace must be torn down when the pre-launch binary check fails")
|
|
}
|
|
if rec, present := st.sessions["mer-1"]; present {
|
|
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
|
|
}
|
|
requireNoPromptDir(t, dataDir, "mer-1")
|
|
}
|
|
|
|
func TestSpawn_ValidatesBinaryAfterEnvPrefix(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookedUp := []string{}
|
|
lookPath := func(name string) (string, error) {
|
|
lookedUp = append(lookedUp, name)
|
|
switch name {
|
|
case "tmux":
|
|
return "/bin/tmux", nil
|
|
case "opencode":
|
|
return "/usr/local/bin/opencode", nil
|
|
default:
|
|
return "", fmt.Errorf("exec: %q: not found", name)
|
|
}
|
|
}
|
|
agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json", "opencode", "--agent", "ao-mer-1"}}
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(lookedUp, []string{"tmux", "opencode"}) {
|
|
t.Fatalf("lookups = %#v, want tmux then opencode", lookedUp)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("runtime.Create calls = %d, want 1", rt.created)
|
|
}
|
|
if !reflect.DeepEqual(rt.lastCfg.Argv, agent.argv) {
|
|
t.Fatalf("runtime argv = %#v, want original argv %#v", rt.lastCfg.Argv, agent.argv)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_RejectsMissingBinaryAfterEnvPrefix(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookedUp := []string{}
|
|
lookPath := func(name string) (string, error) {
|
|
lookedUp = append(lookedUp, name)
|
|
if name == "tmux" {
|
|
return "/bin/tmux", nil
|
|
}
|
|
return "", fmt.Errorf("exec: %q: not found", name)
|
|
}
|
|
agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json", "opencode", "--agent", "ao-mer-1"}}
|
|
m := New(Deps{Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
|
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
|
|
}
|
|
if !reflect.DeepEqual(lookedUp, []string{"tmux", "opencode"}) {
|
|
t.Fatalf("lookups = %#v, want tmux then opencode", lookedUp)
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatal("runtime.Create must NOT run when the env-prefixed agent binary is missing")
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("workspace must be torn down when the pre-launch binary check fails")
|
|
}
|
|
if rec, present := st.sessions["mer-1"]; present {
|
|
t.Fatalf("seed row must be deleted before a runtime handle is live, got %+v", rec)
|
|
}
|
|
}
|
|
|
|
func TestSpawn_RejectsEnvPrefixWithoutBinary(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
agent := launchArgvAgent{argv: []string{"env", "OPENCODE_CONFIG=/tmp/ao/opencode.json"}}
|
|
m := New(Deps{
|
|
Runtime: rt, Agents: singleAgent{agent: agent}, Workspace: ws, Store: st,
|
|
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
|
LookPath: func(name string) (string, error) {
|
|
if name == "tmux" {
|
|
return "/bin/tmux", nil
|
|
}
|
|
return "/bin/" + name, nil
|
|
},
|
|
})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if !errors.Is(err, ports.ErrAgentBinaryNotFound) {
|
|
t.Fatalf("err = %v, want ports.ErrAgentBinaryNotFound", err)
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatal("runtime.Create must NOT run when env-prefixed argv has no binary")
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("workspace must be torn down when env-prefixed argv has no binary")
|
|
}
|
|
}
|
|
|
|
func TestSpawn_RejectsMissingTmuxBeforeSessionRow(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows uses ConPTY, not tmux")
|
|
}
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookPath := func(name string) (string, error) {
|
|
if name == "tmux" {
|
|
return "", fmt.Errorf("exec: %q: not found", name)
|
|
}
|
|
return "/bin/true", nil
|
|
}
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: lookPath})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
if !errors.Is(err, ports.ErrRuntimePrerequisite) || !strings.Contains(err.Error(), "tmux required") {
|
|
t.Fatalf("err = %v, want missing tmux prerequisite", err)
|
|
}
|
|
if len(st.sessions) != 0 {
|
|
t.Fatalf("no session row should be created before runtime prerequisites pass, got %d", len(st.sessions))
|
|
}
|
|
if ws.lastCfg.SessionID != "" || ws.destroyed != 0 {
|
|
t.Fatal("workspace must not be created when tmux is missing")
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatal("runtime must not be created when tmux is missing")
|
|
}
|
|
}
|
|
|
|
func TestSpawn_RejectsUnknownHarness(t *testing.T) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
m := New(Deps{Runtime: rt, Agents: missingAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st}, LookPath: func(string) (string, error) { return "/bin/true", nil }})
|
|
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Harness: "bogus"})
|
|
if !errors.Is(err, ErrUnknownHarness) {
|
|
t.Fatalf("err = %v, want ErrUnknownHarness", err)
|
|
}
|
|
// The harness is rejected before any durable state is created — no seed row,
|
|
// no worktree — so an unknown harness never leaves an orphan behind.
|
|
if len(st.sessions) != 0 {
|
|
t.Fatalf("no session row should be created, got %d", len(st.sessions))
|
|
}
|
|
if ws.lastCfg.SessionID != "" || ws.destroyed != 0 {
|
|
t.Fatal("workspace must not be created for an unknown harness")
|
|
}
|
|
if rt.created != 0 {
|
|
t.Fatal("runtime must not be created for an unknown harness")
|
|
}
|
|
}
|
|
|
|
// pathPinManager builds a manager whose Executable dep is stubbed, plus a
|
|
// buffer capturing its log output, for the hook PATH pin tests.
|
|
func pathPinManager(executable func() (string, error)) (*Manager, *fakeStore, *fakeRuntime, *bytes.Buffer) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
logBuf := &bytes.Buffer{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: rt, Agents: fakeAgents{}, Workspace: &fakeWorkspace{}, Store: st,
|
|
Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
|
LookPath: lookPath, Executable: executable,
|
|
Logger: slog.New(slog.NewTextHandler(logBuf, nil)),
|
|
})
|
|
return m, st, rt, logBuf
|
|
}
|
|
|
|
// TestSpawnAndRestore_PinHookPATHToDaemonBinary covers the activity-tracking
|
|
// fix: the spawned session's PATH must put the daemon executable's directory
|
|
// first, so the bare `ao` in the workspace hook commands resolves to the
|
|
// daemon that installed them, not a foreign `ao` earlier on the user's PATH
|
|
// (e.g. the legacy TypeScript CLI, which has no `hooks` command and silently
|
|
// kills activity tracking).
|
|
func TestSpawnAndRestore_PinHookPATHToDaemonBinary(t *testing.T) {
|
|
daemonExe := filepath.Join(t.TempDir(), "ao")
|
|
want := filepath.Dir(daemonExe) + string(os.PathListSeparator) + "/usr/bin"
|
|
executable := func() (string, error) { return daemonExe, nil }
|
|
|
|
cases := []struct {
|
|
name string
|
|
launch func(m *Manager, st *fakeStore) error
|
|
}{
|
|
{
|
|
name: "spawn",
|
|
launch: func(m *Manager, _ *fakeStore) error {
|
|
_, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "restore",
|
|
launch: func(m *Manager, st *fakeStore) error {
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
|
_, err := m.Restore(ctx, "mer-1")
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv("PATH", "/usr/bin")
|
|
m, st, rt, _ := pathPinManager(executable)
|
|
if err := tc.launch(m, st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := rt.lastCfg.Env["PATH"]; got != want {
|
|
t.Fatalf("runtime env PATH = %q, want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSpawn_HookPATHPinUnavailable asserts the degraded path is loud, not
|
|
// silent: when the daemon executable cannot anchor `ao` resolution, PATH is
|
|
// left to the runtime's inherited default and a warning is logged.
|
|
func TestSpawn_HookPATHPinUnavailable(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
executable func() (string, error)
|
|
}{
|
|
{"executable unresolvable", func() (string, error) { return "", errors.New("no exe") }},
|
|
{"executable not named ao", func() (string, error) { return "/opt/aod/ao-daemon", nil }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m, _, rt, logBuf := pathPinManager(tc.executable)
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, ok := rt.lastCfg.Env["PATH"]; ok {
|
|
t.Fatalf("runtime env PATH = %q, want unset when the pin cannot be applied", got)
|
|
}
|
|
if !strings.Contains(logBuf.String(), "not pinned") {
|
|
t.Fatalf("expected a 'not pinned' warning in the log, got %q", logBuf.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSpawn_ProjectPATHIsPinBase asserts a project's PATH override survives the
|
|
// pin as its base rather than being clobbered or clobbering: the daemon dir
|
|
// still comes first.
|
|
func TestSpawn_ProjectPATHIsPinBase(t *testing.T) {
|
|
daemonExe := filepath.Join(t.TempDir(), "ao")
|
|
m, st, rt, _ := pathPinManager(func() (string, error) { return daemonExe, nil })
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: domain.ProjectConfig{
|
|
Env: map[string]string{"PATH": "/proj/bin"},
|
|
Worker: domain.RoleOverride{Harness: domain.HarnessClaudeCode},
|
|
}}
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := filepath.Dir(daemonExe) + string(os.PathListSeparator) + "/proj/bin"
|
|
if got := rt.lastCfg.Env["PATH"]; got != want {
|
|
t.Fatalf("runtime env PATH = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// ---- SaveAndTeardownAll / RestoreAll tests ----
|
|
|
|
// newLifecycleManager builds a manager wired with a recording workspace fake
|
|
// for the shutdown lifecycle tests.
|
|
func newLifecycleManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: rt,
|
|
Agents: fakeAgents{},
|
|
Workspace: ws,
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
LookPath: lookPath,
|
|
})
|
|
return m, st, rt, ws
|
|
}
|
|
|
|
// TestSaveAndTeardownAll_CaptureOrderAndMarker verifies (a): for a live session
|
|
// with a workspace, SaveAndTeardownAll must call StashUncommitted BEFORE
|
|
// UpsertSessionWorktree (writing preserved_ref) BEFORE ForceDestroy.
|
|
func TestSaveAndTeardownAll_CaptureOrderAndMarker(t *testing.T) {
|
|
m, st, _, ws := newLifecycleManager()
|
|
|
|
// Wire a shared ordered call log so we can assert cross-fake ordering:
|
|
// both fakeStore and fakeWorkspace append to the same slice.
|
|
var sharedLog []string
|
|
st.sharedLog = &sharedLog
|
|
ws.sharedLog = &sharedLog
|
|
|
|
// A live session with a workspace path and runtime handle.
|
|
ws.stashRef = "refs/ao/preserved/mer-1"
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", RuntimeHandleID: "h1"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
|
|
if err := m.SaveAndTeardownAll(ctx); err != nil {
|
|
t.Fatalf("SaveAndTeardownAll err = %v", err)
|
|
}
|
|
|
|
// Stash must come before ForceDestroy in the call log.
|
|
stashIdx, forceIdx := -1, -1
|
|
for i, c := range ws.calls {
|
|
if c == "StashUncommitted:mer-1" {
|
|
stashIdx = i
|
|
}
|
|
if c == "ForceDestroy:mer-1" {
|
|
forceIdx = i
|
|
}
|
|
}
|
|
if stashIdx == -1 {
|
|
t.Fatal("StashUncommitted was not called")
|
|
}
|
|
if forceIdx == -1 {
|
|
t.Fatal("ForceDestroy was not called")
|
|
}
|
|
if stashIdx >= forceIdx {
|
|
t.Fatalf("StashUncommitted (call %d) must come before ForceDestroy (call %d)", stashIdx, forceIdx)
|
|
}
|
|
|
|
// UpsertSessionWorktree (DB write) must be committed BEFORE ForceDestroy.
|
|
// Use the shared ordered log to compare positions across the store and workspace.
|
|
upsertIdx, sharedForceIdx := -1, -1
|
|
for i, c := range sharedLog {
|
|
if c == "UpsertSessionWorktree:mer-1" {
|
|
upsertIdx = i
|
|
}
|
|
if c == "ForceDestroy:mer-1" {
|
|
sharedForceIdx = i
|
|
}
|
|
}
|
|
if upsertIdx == -1 {
|
|
t.Fatal("UpsertSessionWorktree was not called")
|
|
}
|
|
if sharedForceIdx == -1 {
|
|
t.Fatal("ForceDestroy was not recorded in shared log")
|
|
}
|
|
if upsertIdx >= sharedForceIdx {
|
|
t.Fatalf("UpsertSessionWorktree (pos %d) must come before ForceDestroy (pos %d) in shared call log %v", upsertIdx, sharedForceIdx, sharedLog)
|
|
}
|
|
|
|
// DB write (UpsertSessionWorktree) must have recorded the correct row.
|
|
rows := st.worktrees["mer-1"]
|
|
if len(rows) == 0 {
|
|
t.Fatal("UpsertSessionWorktree was not called: no worktree row for mer-1")
|
|
}
|
|
if rows[0].PreservedRef != "refs/ao/preserved/mer-1" {
|
|
t.Fatalf("preserved_ref = %q, want refs/ao/preserved/mer-1", rows[0].PreservedRef)
|
|
}
|
|
|
|
// The session must be marked terminated.
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("session must be terminated after SaveAndTeardownAll")
|
|
}
|
|
}
|
|
|
|
func TestRetireForReplacementCapturesAndReleasesWorkspace(t *testing.T) {
|
|
m, st, rt, ws := newLifecycleManager()
|
|
var sharedLog []string
|
|
st.sharedLog = &sharedLog
|
|
ws.sharedLog = &sharedLog
|
|
ws.stashRef = "refs/ao/preserved/mer-orch"
|
|
st.sessions["mer-orch"] = domain.SessionRecord{
|
|
ID: "mer-orch",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindOrchestrator,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{
|
|
SessionID: "mer-orch",
|
|
RepoName: domain.RootWorkspaceRepoName,
|
|
Branch: "ao/mer-orchestrator",
|
|
WorktreePath: "/ws/mer-orch",
|
|
PreservedRef: "refs/ao/preserved/old",
|
|
}}
|
|
|
|
if err := m.RetireForReplacement(ctx, "mer-orch"); err != nil {
|
|
t.Fatalf("RetireForReplacement err = %v", err)
|
|
}
|
|
|
|
if rows := st.worktrees["mer-orch"]; len(rows) != 0 {
|
|
t.Fatalf("replacement retirement must not write restore markers, got %#v", rows)
|
|
}
|
|
if !st.sessions["mer-orch"].IsTerminated {
|
|
t.Fatal("retired orchestrator must be marked terminated")
|
|
}
|
|
if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" {
|
|
t.Fatalf("runtime destroyed = %d ids=%v, want orch-handle", rt.destroyed, rt.destroyedIDs)
|
|
}
|
|
|
|
stashIdx, deleteIdx, forceIdx := -1, -1, -1
|
|
for i, c := range sharedLog {
|
|
switch c {
|
|
case "StashUncommitted:mer-orch":
|
|
stashIdx = i
|
|
case "DeleteSessionWorktrees:mer-orch":
|
|
deleteIdx = i
|
|
case "ForceDestroy:mer-orch":
|
|
forceIdx = i
|
|
}
|
|
}
|
|
if stashIdx == -1 || deleteIdx == -1 || forceIdx == -1 {
|
|
t.Fatalf("missing expected calls in shared log: %v", sharedLog)
|
|
}
|
|
if stashIdx >= deleteIdx || deleteIdx >= forceIdx {
|
|
t.Fatalf("replacement retire must capture, clear restore marker, then force release; log=%v", sharedLog)
|
|
}
|
|
}
|
|
|
|
func TestRetireForReplacementForceDestroyFailureLeavesSessionActive(t *testing.T) {
|
|
m, st, rt, ws := newLifecycleManager()
|
|
ws.forceDestroyErr = errors.New("worktree still registered")
|
|
ws.stashRef = "refs/ao/preserved/mer-orch"
|
|
st.sessions["mer-orch"] = domain.SessionRecord{
|
|
ID: "mer-orch",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindOrchestrator,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{
|
|
SessionID: "mer-orch",
|
|
RepoName: domain.RootWorkspaceRepoName,
|
|
Branch: "ao/mer-orchestrator",
|
|
WorktreePath: "/ws/mer-orch",
|
|
PreservedRef: "refs/ao/preserved/old",
|
|
}}
|
|
|
|
err := m.RetireForReplacement(ctx, "mer-orch")
|
|
if err == nil || !strings.Contains(err.Error(), "force destroy") {
|
|
t.Fatalf("RetireForReplacement err = %v, want force destroy failure", err)
|
|
}
|
|
if st.sessions["mer-orch"].IsTerminated {
|
|
t.Fatal("session must remain active so retry can retire it again")
|
|
}
|
|
if rt.destroyed != 1 {
|
|
t.Fatalf("runtime destroyed = %d, want 1 before workspace release", rt.destroyed)
|
|
}
|
|
if ws.stashCalls != 1 {
|
|
t.Fatalf("stash calls = %d, want 1", ws.stashCalls)
|
|
}
|
|
}
|
|
|
|
func TestRetireForReplacementRuntimeDestroyFailureBlocksWorkspaceRelease(t *testing.T) {
|
|
m, st, rt, ws := newLifecycleManager()
|
|
rt.destroyErr = errors.New("tmux transient")
|
|
ws.stashRef = "refs/ao/preserved/mer-orch"
|
|
st.sessions["mer-orch"] = domain.SessionRecord{
|
|
ID: "mer-orch",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindOrchestrator,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-orch", Branch: "ao/mer-orchestrator", RuntimeHandleID: "orch-handle"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
st.worktrees["mer-orch"] = []domain.SessionWorktreeRecord{{
|
|
SessionID: "mer-orch",
|
|
RepoName: domain.RootWorkspaceRepoName,
|
|
Branch: "ao/mer-orchestrator",
|
|
WorktreePath: "/ws/mer-orch",
|
|
PreservedRef: "refs/ao/preserved/old",
|
|
}}
|
|
|
|
err := m.RetireForReplacement(ctx, "mer-orch")
|
|
if err == nil || !strings.Contains(err.Error(), "runtime") {
|
|
t.Fatalf("RetireForReplacement err = %v, want runtime failure", err)
|
|
}
|
|
if st.sessions["mer-orch"].IsTerminated {
|
|
t.Fatal("session must remain active when runtime destroy fails")
|
|
}
|
|
if rt.destroyed != 1 || rt.destroyedIDs[0] != "orch-handle" {
|
|
t.Fatalf("runtime destroyed = %d ids=%v, want one attempt for orch-handle", rt.destroyed, rt.destroyedIDs)
|
|
}
|
|
for _, call := range ws.calls {
|
|
if call == "ForceDestroy:mer-orch" {
|
|
t.Fatalf("ForceDestroy must not run after runtime destroy failure; calls=%v", ws.calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef verifies that a clean
|
|
// worktree (StashUncommitted returns "") still writes a worktree row (with
|
|
// empty preserved_ref). The row's presence is the shutdown-saved marker.
|
|
func TestSaveAndTeardownAll_CleanWorktreeWritesEmptyRef(t *testing.T) {
|
|
m, st, _, ws := newLifecycleManager()
|
|
ws.stashRef = "" // clean worktree
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", RuntimeHandleID: "h1"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
|
|
if err := m.SaveAndTeardownAll(ctx); err != nil {
|
|
t.Fatalf("SaveAndTeardownAll err = %v", err)
|
|
}
|
|
|
|
rows := st.worktrees["mer-1"]
|
|
if len(rows) == 0 {
|
|
t.Fatal("clean worktree must still write a session_worktrees row as the shutdown-saved marker")
|
|
}
|
|
if rows[0].PreservedRef != "" {
|
|
t.Fatalf("preserved_ref = %q, want empty for clean worktree", rows[0].PreservedRef)
|
|
}
|
|
}
|
|
|
|
// TestSaveAndTeardownAll_SkipsNoWorkspacePath: sessions without a workspace
|
|
// path are skipped (spawn failed before workspace.Create).
|
|
func TestSaveAndTeardownAll_SkipsNoWorkspacePath(t *testing.T) {
|
|
m, st, _, ws := newLifecycleManager()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Metadata: domain.SessionMetadata{}, // no workspace path
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
|
|
if err := m.SaveAndTeardownAll(ctx); err != nil {
|
|
t.Fatalf("SaveAndTeardownAll err = %v", err)
|
|
}
|
|
|
|
if len(ws.calls) != 0 {
|
|
t.Fatalf("no workspace calls expected for sessions with no workspace path, got %v", ws.calls)
|
|
}
|
|
if len(st.worktrees["mer-1"]) != 0 {
|
|
t.Fatal("no worktree row should be written for sessions with no workspace path")
|
|
}
|
|
}
|
|
|
|
// TestSaveAndTeardownAll_SkipsAlreadyTerminated: already-terminated sessions
|
|
// are skipped.
|
|
func TestSaveAndTeardownAll_SkipsAlreadyTerminated(t *testing.T) {
|
|
m, st, _, ws := newLifecycleManager()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
|
|
if err := m.SaveAndTeardownAll(ctx); err != nil {
|
|
t.Fatalf("SaveAndTeardownAll err = %v", err)
|
|
}
|
|
if len(ws.calls) != 0 {
|
|
t.Fatalf("already-terminated sessions must be skipped, got calls %v", ws.calls)
|
|
}
|
|
}
|
|
|
|
// TestSaveAndTeardownAll_NoKindFilter: both worker and orchestrator sessions
|
|
// are saved (no kind filter).
|
|
func TestSaveAndTeardownAll_NoKindFilter(t *testing.T) {
|
|
m, st, _, _ := newLifecycleManager()
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindWorker,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", RuntimeHandleID: "h1"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
st.sessions["mer-2"] = domain.SessionRecord{
|
|
ID: "mer-2", ProjectID: "mer", Kind: domain.KindOrchestrator,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-2", Branch: "ao/mer-orchestrator", RuntimeHandleID: "h2"},
|
|
Activity: domain.Activity{State: domain.ActivityActive},
|
|
}
|
|
|
|
if err := m.SaveAndTeardownAll(ctx); err != nil {
|
|
t.Fatalf("SaveAndTeardownAll err = %v", err)
|
|
}
|
|
|
|
if len(st.worktrees["mer-1"]) == 0 {
|
|
t.Error("worker session mer-1 must be saved")
|
|
}
|
|
if len(st.worktrees["mer-2"]) == 0 {
|
|
t.Error("orchestrator session mer-2 must be saved")
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Error("worker session mer-1 must be terminated")
|
|
}
|
|
if !st.sessions["mer-2"].IsTerminated {
|
|
t.Error("orchestrator session mer-2 must be terminated")
|
|
}
|
|
}
|
|
|
|
// TestRestoreAll_RestoresBothWorkerAndOrchestrator verifies (b): RestoreAll
|
|
// restores both a worker and an orchestrator session saved by SaveAndTeardownAll.
|
|
func TestRestoreAll_RestoresBothWorkerAndOrchestrator(t *testing.T) {
|
|
m, st, rt, _ := newLifecycleManager()
|
|
|
|
// Seed two terminated sessions that were saved by SaveAndTeardownAll
|
|
// (presence of session_worktrees rows is the marker).
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
st.sessions["mer-2"] = domain.SessionRecord{
|
|
ID: "mer-2",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindOrchestrator,
|
|
Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-2", Branch: "ao/mer-orchestrator", AgentSessionID: "agent-o"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
// Write the shutdown-saved marker rows.
|
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{{SessionID: "mer-1", RepoName: "__root__", PreservedRef: ""}}
|
|
st.worktrees["mer-2"] = []domain.SessionWorktreeRecord{{SessionID: "mer-2", RepoName: "__root__", PreservedRef: ""}}
|
|
|
|
if err := m.RestoreAll(ctx); err != nil {
|
|
t.Fatalf("RestoreAll err = %v", err)
|
|
}
|
|
|
|
if rt.created != 2 {
|
|
t.Fatalf("RestoreAll must relaunch both sessions, runtime.Create called %d times", rt.created)
|
|
}
|
|
if st.sessions["mer-1"].IsTerminated {
|
|
t.Error("worker session mer-1 must be live after RestoreAll")
|
|
}
|
|
if st.sessions["mer-2"].IsTerminated {
|
|
t.Error("orchestrator session mer-2 must be live after RestoreAll")
|
|
}
|
|
}
|
|
|
|
func TestRestoreAll_ConsumesMarkersAfterSuccessfulRestore(t *testing.T) {
|
|
m, st, rt, _ := newLifecycleManager()
|
|
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{
|
|
{SessionID: "mer-1", RepoName: domain.RootWorkspaceRepoName, WorktreePath: "/ws/mer-1"},
|
|
}
|
|
|
|
if err := m.RestoreAll(ctx); err != nil {
|
|
t.Fatalf("RestoreAll err = %v", err)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("RestoreAll must relaunch session, runtime.Create called %d times", rt.created)
|
|
}
|
|
if rows := st.worktrees["mer-1"]; len(rows) != 0 {
|
|
t.Fatalf("consumed restore marker = %+v, want deleted", rows)
|
|
}
|
|
}
|
|
|
|
// TestRestoreAll_SkipsSessionsKilledBeforeShutdown verifies (c): a session
|
|
// the user killed BEFORE shutdown has no session_worktrees row and must NOT
|
|
// be resurrected.
|
|
func TestRestoreAll_SkipsSessionsKilledBeforeShutdown(t *testing.T) {
|
|
m, st, rt, _ := newLifecycleManager()
|
|
|
|
// This session was killed by the user before shutdown: IsTerminated=true,
|
|
// but no session_worktrees row (SaveAndTeardownAll skipped it).
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", Prompt: "do it"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
// Deliberately no entry in st.worktrees for mer-1.
|
|
|
|
if err := m.RestoreAll(ctx); err != nil {
|
|
t.Fatalf("RestoreAll err = %v", err)
|
|
}
|
|
|
|
if rt.created != 0 {
|
|
t.Fatalf("user-killed session must not be restored, runtime.Create called %d times", rt.created)
|
|
}
|
|
if !st.sessions["mer-1"].IsTerminated {
|
|
t.Error("user-killed session must remain terminated")
|
|
}
|
|
}
|
|
|
|
// TestRestoreAll_AppliesPreservedRef: when the session_worktrees row has a
|
|
// non-empty preserved_ref, RestoreAll calls ApplyPreserved after workspace
|
|
// restore but before relaunching.
|
|
func TestRestoreAll_AppliesPreservedRef(t *testing.T) {
|
|
m, st, rt, ws := newLifecycleManager()
|
|
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{
|
|
{SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1"},
|
|
}
|
|
|
|
if err := m.RestoreAll(ctx); err != nil {
|
|
t.Fatalf("RestoreAll err = %v", err)
|
|
}
|
|
|
|
applied := false
|
|
for _, c := range ws.calls {
|
|
if c == "ApplyPreserved:mer-1" {
|
|
applied = true
|
|
}
|
|
}
|
|
if !applied {
|
|
t.Fatal("ApplyPreserved was not called for session with preserved_ref")
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatal("session must still be relaunched even after ApplyPreserved")
|
|
}
|
|
}
|
|
|
|
// TestRestoreAll_ConflictLogsAndContinues: when ApplyPreserved returns
|
|
// ErrPreservedConflict, RestoreAll logs and continues (still relaunches).
|
|
func TestRestoreAll_ConflictLogsAndContinues(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{applyErr: fmt.Errorf("conflict: %w", ports.ErrPreservedConflict)}
|
|
var logBuf bytes.Buffer
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{
|
|
Runtime: rt,
|
|
Agents: fakeAgents{},
|
|
Workspace: ws,
|
|
Store: st,
|
|
Messenger: &fakeMessenger{},
|
|
Lifecycle: &fakeLCM{store: st},
|
|
LookPath: lookPath,
|
|
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
|
})
|
|
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1",
|
|
ProjectID: "mer",
|
|
Kind: domain.KindWorker,
|
|
Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "ao/mer-1/root", AgentSessionID: "agent-w"},
|
|
Activity: domain.Activity{State: domain.ActivityExited},
|
|
}
|
|
st.worktrees["mer-1"] = []domain.SessionWorktreeRecord{
|
|
{SessionID: "mer-1", RepoName: "__root__", PreservedRef: "refs/ao/preserved/mer-1"},
|
|
}
|
|
|
|
if err := m.RestoreAll(ctx); err != nil {
|
|
t.Fatalf("RestoreAll err = %v; conflict must not abort", err)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("session must still relaunch after conflict, runtime.Create called %d times", rt.created)
|
|
}
|
|
}
|
|
|
|
func TestReconcileLive_DeadSessionStashedAndTerminated(t *testing.T) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // handle not alive
|
|
ws := &fakeWorkspace{stashRef: "refs/ao/preserved/s1"}
|
|
lcm := &fakeLCM{store: st}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath})
|
|
|
|
rec := domain.SessionRecord{
|
|
ID: "s1",
|
|
ProjectID: "p1",
|
|
IsTerminated: false,
|
|
Metadata: domain.SessionMetadata{
|
|
Branch: "ao/s1/root", WorkspacePath: "/wt/s1", RuntimeHandleID: "s1",
|
|
},
|
|
}
|
|
|
|
if err := m.reconcileLive(context.Background(), rec); err != nil {
|
|
t.Fatalf("reconcileLive: %v", err)
|
|
}
|
|
if ws.stashCalls != 1 {
|
|
t.Fatalf("StashUncommitted calls = %d, want 1", ws.stashCalls)
|
|
}
|
|
if lcm.terminated["s1"] != 1 {
|
|
t.Fatalf("MarkTerminated(s1) = %d, want 1", lcm.terminated["s1"])
|
|
}
|
|
if rt.destroyed != 0 {
|
|
t.Fatalf("Destroy calls = %d, want 0 (dead session: no tmux to kill)", rt.destroyed)
|
|
}
|
|
// The crash-orphaned session must be saved for restore, exactly like a
|
|
// graceful shutdown: a session_worktrees marker carrying the preserve ref,
|
|
// and the worktree torn down so RestoreAll re-creates it clean.
|
|
rows := st.worktrees["s1"]
|
|
if len(rows) != 1 || rows[0].PreservedRef != "refs/ao/preserved/s1" {
|
|
t.Fatalf("session_worktrees marker for s1 = %+v, want one row with the preserve ref", rows)
|
|
}
|
|
foundForceDestroy := false
|
|
for _, c := range ws.calls {
|
|
if c == "ForceDestroy:s1" {
|
|
foundForceDestroy = true
|
|
}
|
|
}
|
|
if !foundForceDestroy {
|
|
t.Fatalf("reconcileLive must ForceDestroy the worktree after capturing work; calls = %v", ws.calls)
|
|
}
|
|
}
|
|
|
|
func TestReconcileLive_AliveSessionAdoptedNoop(t *testing.T) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{aliveByHandle: map[string]bool{"s2": true}}
|
|
ws := &fakeWorkspace{}
|
|
lcm := &fakeLCM{store: st}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath})
|
|
|
|
rec := domain.SessionRecord{
|
|
ID: "s2", ProjectID: "p1", IsTerminated: false,
|
|
Metadata: domain.SessionMetadata{Branch: "ao/s2/root", WorkspacePath: "/wt/s2", RuntimeHandleID: "s2"},
|
|
}
|
|
|
|
if err := m.reconcileLive(context.Background(), rec); err != nil {
|
|
t.Fatalf("reconcileLive: %v", err)
|
|
}
|
|
if ws.stashCalls != 0 || lcm.terminated["s2"] != 0 || rt.destroyed != 0 {
|
|
t.Fatalf("adopt should be a no-op: stash=%d term=%d destroy=%d", ws.stashCalls, lcm.terminated["s2"], rt.destroyed)
|
|
}
|
|
}
|
|
|
|
// TestReconcileLive_ProbeErrorIsNotDeath locks the invariant that a failed
|
|
// IsAlive probe is NOT treated as proof that the session is dead. reconcileLive
|
|
// must propagate the error and must NOT stash, terminate, or destroy.
|
|
func TestReconcileLive_ProbeErrorIsNotDeath(t *testing.T) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{aliveErr: errors.New("probe boom")}
|
|
ws := &fakeWorkspace{}
|
|
lcm := &fakeLCM{store: st}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath})
|
|
|
|
rec := domain.SessionRecord{
|
|
ID: "s3",
|
|
ProjectID: "p1",
|
|
IsTerminated: false,
|
|
Metadata: domain.SessionMetadata{
|
|
Branch: "ao/s3/root", WorkspacePath: "/wt/s3", RuntimeHandleID: "s3",
|
|
},
|
|
}
|
|
|
|
err := m.reconcileLive(context.Background(), rec)
|
|
if err == nil {
|
|
t.Fatal("reconcileLive: expected non-nil error on probe failure, got nil")
|
|
}
|
|
if ws.stashCalls != 0 {
|
|
t.Fatalf("StashUncommitted calls = %d, want 0 (probe error is not death)", ws.stashCalls)
|
|
}
|
|
if lcm.terminated["s3"] != 0 {
|
|
t.Fatalf("MarkTerminated(s3) = %d, want 0 (probe error is not death)", lcm.terminated["s3"])
|
|
}
|
|
if rt.destroyed != 0 {
|
|
t.Fatalf("Destroy calls = %d, want 0 (probe error is not death)", rt.destroyed)
|
|
}
|
|
}
|
|
|
|
// TestReconcile_AdoptAcrossDaemonRestart is the end-to-end durability proof for
|
|
// #2335: it drives the full boot-time Reconcile pass over the exact mix of
|
|
// session states a daemon restart/upgrade leaves behind and asserts agent
|
|
// sessions are decoupled from the daemon's lifetime:
|
|
//
|
|
// - an alive orchestrator is ADOPTED in place: same id, still live, its runtime
|
|
// never torn down, and NO new session minted (the id-increment regression
|
|
// guard: adoption failure used to mint a fresh orchestrator id 14->15->16).
|
|
// - an alive worker is adopted as a no-op.
|
|
// - a worker whose runtime died with the daemon has its work captured (stashed
|
|
// into a preserve ref, restore marker written) and is relaunched on this same
|
|
// boot under its ORIGINAL id.
|
|
// - a truly-dead session with no restore marker is NOT resurrected.
|
|
func TestReconcile_AdoptAcrossDaemonRestart(t *testing.T) {
|
|
st := newFakeStore()
|
|
st.projects["mer"] = domain.ProjectRecord{ID: "mer", Config: testRoleAgents()}
|
|
rt := &fakeRuntime{aliveByHandle: map[string]bool{
|
|
"orch": true, // orchestrator runtime survived the daemon exit
|
|
"w-alive": true, // worker runtime survived the daemon exit
|
|
// "w-dead" is absent -> that worker's runtime died with the daemon.
|
|
}}
|
|
ws := &fakeWorkspace{stashRef: "refs/ao/preserved/mer-3"}
|
|
lcm := &fakeLCM{store: st}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath})
|
|
|
|
// Alive orchestrator: the promptless session whose adoption failure used to
|
|
// mint a fresh orchestrator id. It must be adopted in place.
|
|
st.sessions["mer-1"] = domain.SessionRecord{
|
|
ID: "mer-1", ProjectID: "mer", Kind: domain.KindOrchestrator, Harness: domain.HarnessClaudeCode,
|
|
Metadata: domain.SessionMetadata{Branch: "ao/mer-1/root", WorkspacePath: "/ws/mer-1", RuntimeHandleID: "orch"},
|
|
}
|
|
// Alive worker: adopted as a no-op.
|
|
st.sessions["mer-2"] = domain.SessionRecord{
|
|
ID: "mer-2", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode,
|
|
Metadata: domain.SessionMetadata{Branch: "ao/mer-2/root", WorkspacePath: "/ws/mer-2", RuntimeHandleID: "w-alive", AgentSessionID: "agent-2"},
|
|
}
|
|
// Dead worker: its runtime died with the daemon; capture + relaunch under same id.
|
|
st.sessions["mer-3"] = domain.SessionRecord{
|
|
ID: "mer-3", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode,
|
|
Metadata: domain.SessionMetadata{Branch: "ao/mer-3/root", WorkspacePath: "/ws/mer-3", RuntimeHandleID: "w-dead", AgentSessionID: "agent-3"},
|
|
}
|
|
// Truly-dead session the user killed before restart (terminated, no marker).
|
|
st.sessions["mer-4"] = domain.SessionRecord{
|
|
ID: "mer-4", ProjectID: "mer", Kind: domain.KindWorker, Harness: domain.HarnessClaudeCode,
|
|
IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited},
|
|
Metadata: domain.SessionMetadata{Branch: "ao/mer-4/root", WorkspacePath: "/ws/mer-4"},
|
|
}
|
|
|
|
if err := m.Reconcile(ctx); err != nil {
|
|
t.Fatalf("Reconcile: %v", err)
|
|
}
|
|
|
|
// Alive orchestrator + worker adopted in place: same id, still live.
|
|
if st.sessions["mer-1"].IsTerminated {
|
|
t.Fatal("alive orchestrator must be adopted in place, not terminated")
|
|
}
|
|
if st.sessions["mer-2"].IsTerminated {
|
|
t.Fatal("alive worker must be adopted in place, not terminated")
|
|
}
|
|
// No id increment: Reconcile must never mint a new session row.
|
|
if st.num != 0 {
|
|
t.Fatalf("Reconcile minted %d new session(s); adoption must reuse existing ids", st.num)
|
|
}
|
|
// Adopted runtimes were never torn down.
|
|
if rt.destroyed != 0 {
|
|
t.Fatalf("adopted sessions must not be destroyed; Destroy called %d times", rt.destroyed)
|
|
}
|
|
// Dead worker captured, then relaunched under its original id on this same boot.
|
|
if lcm.terminated["mer-3"] != 1 {
|
|
t.Fatalf("dead worker must be marked terminated once before relaunch; got %d", lcm.terminated["mer-3"])
|
|
}
|
|
if st.sessions["mer-3"].IsTerminated {
|
|
t.Fatal("dead worker must be relaunched (not terminated) after Reconcile")
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("exactly one runtime relaunch expected (the dead worker); got %d", rt.created)
|
|
}
|
|
// One-shot restore marker consumed so it never outlives one restart (#2319).
|
|
if rows := st.worktrees["mer-3"]; len(rows) != 0 {
|
|
t.Fatalf("restore marker for mer-3 must be deleted after relaunch; got %+v", rows)
|
|
}
|
|
// Truly-dead, unmarked session is NOT resurrected.
|
|
if !st.sessions["mer-4"].IsTerminated {
|
|
t.Fatal("terminated session with no restore marker must stay terminated")
|
|
}
|
|
}
|
|
|
|
func TestReconcileReap_TerminatedButAliveTmuxDestroyed(t *testing.T) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{aliveByHandle: map[string]bool{"t1": true}}
|
|
ws := &fakeWorkspace{}
|
|
lcm := &fakeLCM{store: st}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath})
|
|
|
|
rec := domain.SessionRecord{
|
|
ID: "t1", ProjectID: "p1", IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{RuntimeHandleID: "t1"},
|
|
}
|
|
|
|
if err := m.reconcileReap(context.Background(), rec); err != nil {
|
|
t.Fatalf("reconcileReap: %v", err)
|
|
}
|
|
if len(rt.destroyedIDs) != 1 || rt.destroyedIDs[0] != "t1" {
|
|
t.Fatalf("destroyedIDs = %v, want [t1]", rt.destroyedIDs)
|
|
}
|
|
}
|
|
|
|
func TestReconcileReap_TerminatedAndDeadTmuxLeftAlone(t *testing.T) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{aliveByHandle: map[string]bool{}} // t2 not alive
|
|
ws := &fakeWorkspace{}
|
|
lcm := &fakeLCM{store: st}
|
|
lookPath := func(string) (string, error) { return "/bin/true", nil }
|
|
m := New(Deps{Runtime: rt, Agents: fakeAgents{}, Workspace: ws, Store: st, Messenger: &fakeMessenger{}, Lifecycle: lcm, LookPath: lookPath})
|
|
|
|
rec := domain.SessionRecord{
|
|
ID: "t2", ProjectID: "p1", IsTerminated: true,
|
|
Metadata: domain.SessionMetadata{RuntimeHandleID: "t2"},
|
|
}
|
|
if err := m.reconcileReap(context.Background(), rec); err != nil {
|
|
t.Fatalf("reconcileReap: %v", err)
|
|
}
|
|
if rt.destroyed != 0 {
|
|
t.Fatalf("Destroy calls = %d, want 0", rt.destroyed)
|
|
}
|
|
}
|