// SPDX-License-Identifier: EUPL-1.2 package runner import ( "context" "testing" "dappco.re/go/agent/pkg/agentic" "dappco.re/go/agent/pkg/messages" core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- New --- func TestRunner_New_Good(t *testing.T) { svc := New() assert.NotNil(t, svc) assert.NotNil(t, svc.workspaces) assert.NotNil(t, svc.backoff) assert.NotNil(t, svc.failCount) assert.False(t, svc.frozen, "New() doesn't freeze — startRunner does based on env") } func TestRunner_New_Bad_NoServiceRuntime(t *testing.T) { svc := New() assert.Nil(t, svc.ServiceRuntime) assert.False(t, svc.IsFrozen(), "New() doesn't set frozen — startRunner does") } func TestRunner_New_Ugly_MultipleInstances(t *testing.T) { a := New() b := New() assert.NotSame(t, a, b, "each call returns a fresh instance") assert.NotSame(t, a.workspaces, b.workspaces) } // --- Register --- func TestRunner_Register_Good(t *testing.T) { c := core.New(core.WithOption("name", "test")) r := Register(c) assert.True(t, r.OK) assert.NotNil(t, r.Value) } func TestRunner_Register_Bad_NilCore(t *testing.T) { assert.Panics(t, func() { Register(nil) }) } func TestRunner_Register_Ugly_ConfigLoaded(t *testing.T) { c := core.New(core.WithOption("name", "test")) Register(c) // Config should have agents.concurrency set (even if defaults) r := c.Config().Get("agents.dispatch") assert.NotNil(t, r) } // --- IsFrozen --- func TestRunner_IsFrozen_Good(t *testing.T) { svc := New() svc.frozen = true assert.True(t, svc.IsFrozen()) } func TestRunner_IsFrozen_Bad_AfterUnfreeze(t *testing.T) { svc := New() svc.frozen = false assert.False(t, svc.IsFrozen()) } func TestRunner_IsFrozen_Ugly_ToggleRapidly(t *testing.T) { svc := New() for i := 0; i < 100; i++ { svc.frozen = i%2 == 0 } // i=99 → 99%2==1 → false. Last write wins. assert.False(t, svc.IsFrozen(), "last toggle wins") } // --- TrackWorkspace --- func TestRunner_TrackWorkspace_Good(t *testing.T) { svc := New() svc.TrackWorkspace("core/go-io/dev", &WorkspaceStatus{ Status: "running", Agent: "codex", Repo: "go-io", PID: 12345, }) r := svc.workspaces.Get("core/go-io/dev") assert.True(t, r.OK) } func TestRunner_TrackWorkspace_Good_AgenticStatus(t *testing.T) { svc := New() svc.TrackWorkspace("core/go-io/dev", &agentic.WorkspaceStatus{ Status: "running", Agent: "codex", Repo: "go-io", PID: 12345, }) r := svc.workspaces.Get("core/go-io/dev") assert.True(t, r.OK) } func TestRunner_TrackWorkspace_Bad_NilWorkspaces(t *testing.T) { svc := &Service{} assert.NotPanics(t, func() { svc.TrackWorkspace("test", &WorkspaceStatus{Status: "running"}) }) } func TestRunner_TrackWorkspace_Ugly_AnyType(t *testing.T) { svc := New() // TrackWorkspace accepts any — JSON round-trip converts svc.TrackWorkspace("test", map[string]any{ "status": "running", "agent": "codex", "repo": "go-io", }) r := svc.workspaces.Get("test") assert.True(t, r.OK) ws := r.Value.(*WorkspaceStatus) assert.Equal(t, "running", ws.Status) assert.Equal(t, "codex", ws.Agent) } // --- Workspaces --- func TestRunner_Workspaces_Good(t *testing.T) { svc := New() assert.NotNil(t, svc.Workspaces()) assert.Equal(t, 0, svc.Workspaces().Len()) } func TestRunner_Workspaces_Bad_AfterTrack(t *testing.T) { svc := New() svc.TrackWorkspace("ws-1", &WorkspaceStatus{Status: "running"}) assert.Equal(t, 1, svc.Workspaces().Len()) } func TestRunner_Workspaces_Ugly_OverwriteSameName(t *testing.T) { svc := New() svc.TrackWorkspace("ws-1", &WorkspaceStatus{Status: "running"}) svc.TrackWorkspace("ws-1", &WorkspaceStatus{Status: "completed"}) assert.Equal(t, 1, svc.Workspaces().Len()) r := svc.workspaces.Get("ws-1") ws := r.Value.(*WorkspaceStatus) assert.Equal(t, "completed", ws.Status) } // --- Poke --- func TestRunner_Poke_Good(t *testing.T) { svc := New() svc.pokeCh = make(chan struct{}, 1) svc.Poke() assert.Len(t, svc.pokeCh, 1) } func TestRunner_Poke_Bad_NilChannel(t *testing.T) { svc := New() assert.NotPanics(t, func() { svc.Poke() }) } func TestRunner_Poke_Ugly_DoublePoke(t *testing.T) { svc := New() svc.pokeCh = make(chan struct{}, 1) svc.Poke() svc.Poke() // second poke is a no-op (channel full) assert.Len(t, svc.pokeCh, 1) } // --- Actions --- func TestRunner_ActionStatus_Good(t *testing.T) { svc := New() svc.TrackWorkspace("ws-1", &WorkspaceStatus{Status: "running"}) svc.TrackWorkspace("ws-2", &WorkspaceStatus{Status: "completed"}) svc.TrackWorkspace("ws-3", &WorkspaceStatus{Status: "queued"}) r := svc.actionStatus(context.Background(), core.NewOptions()) assert.True(t, r.OK) m := r.Value.(map[string]int) assert.Equal(t, 1, m["running"]) assert.Equal(t, 1, m["completed"]) assert.Equal(t, 1, m["queued"]) assert.Equal(t, 3, m["total"]) } func TestRunner_ActionStatus_Bad_Empty(t *testing.T) { svc := New() r := svc.actionStatus(context.Background(), core.NewOptions()) assert.True(t, r.OK) m := r.Value.(map[string]int) assert.Equal(t, 0, m["total"]) } func TestRunner_ActionStatus_Ugly_AllStatuses(t *testing.T) { svc := New() for _, s := range []string{"running", "queued", "completed", "merged", "ready-for-review", "failed", "blocked"} { svc.TrackWorkspace("ws-"+s, &WorkspaceStatus{Status: s}) } r := svc.actionStatus(context.Background(), core.NewOptions()) m := r.Value.(map[string]int) assert.Equal(t, 7, m["total"]) assert.Equal(t, 3, m["completed"]) // completed + merged + ready-for-review assert.Equal(t, 2, m["failed"]) // failed + blocked } func TestRunner_ActionStart_Good(t *testing.T) { svc := New() svc.frozen = true svc.pokeCh = make(chan struct{}, 1) assert.True(t, svc.IsFrozen()) r := svc.actionStart(context.Background(), core.NewOptions()) assert.True(t, r.OK) assert.False(t, svc.IsFrozen()) } func TestRunner_ActionStop_Good(t *testing.T) { svc := New() svc.frozen = false r := svc.actionStop(context.Background(), core.NewOptions()) assert.True(t, r.OK) assert.True(t, svc.IsFrozen()) } func TestRunner_ActionKill_Good_ClearsQueuedWorkspaces(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) wsRoot := core.JoinPath(root, "workspace") queuedDir := core.JoinPath(wsRoot, "task-queued") runningDir := core.JoinPath(wsRoot, "task-running") require.True(t, fs.EnsureDir(queuedDir).OK) require.True(t, fs.EnsureDir(runningDir).OK) require.True(t, WriteStatus(queuedDir, &WorkspaceStatus{ Status: "queued", Agent: "codex", Repo: "go-io", Task: "Queued work", }).OK) require.True(t, WriteStatus(runningDir, &WorkspaceStatus{ Status: "running", Agent: "claude", Repo: "go-log", Task: "Running work", }).OK) svc := New() result := svc.actionKill(context.Background(), core.NewOptions()) require.True(t, result.OK) assert.Contains(t, result.Value.(string), "cleared 1 queued") assert.False(t, fs.Read(core.JoinPath(queuedDir, "status.json")).OK, "queued workspace should be deleted") running := mustReadStatus(t, runningDir) assert.Equal(t, "failed", running.Status) assert.Equal(t, 0, running.PID) } func TestRunner_ActionKill_Bad_EmptyWorkspaceRoot(t *testing.T) { t.Setenv("CORE_WORKSPACE", "") svc := New() result := svc.actionKill(context.Background(), core.NewOptions()) require.True(t, result.OK) assert.Contains(t, result.Value.(string), "cleared 0 queued") } func TestRunner_ActionKill_Ugly_InvalidStatusFile(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) wsRoot := core.JoinPath(root, "workspace") wsDir := core.JoinPath(wsRoot, "task-bad") require.True(t, fs.EnsureDir(wsDir).OK) require.True(t, fs.Write(core.JoinPath(wsDir, "status.json"), "{not-json").OK) svc := New() result := svc.actionKill(context.Background(), core.NewOptions()) require.True(t, result.OK) assert.Contains(t, result.Value.(string), "cleared 0 queued") } func TestRunner_ActionDispatch_Bad_Frozen(t *testing.T) { svc := New() svc.frozen = true r := svc.actionDispatch(context.Background(), core.NewOptions( core.Option{Key: "agent", Value: "codex"}, )) assert.False(t, r.OK, "should deny when frozen") } func TestRunner_ActionDispatch_Good_Unfrozen(t *testing.T) { svc := New() svc.frozen = false r := svc.actionDispatch(context.Background(), core.NewOptions( core.Option{Key: "agent", Value: "codex"}, )) assert.True(t, r.OK, "should allow when unfrozen and no concurrency limit hit") } // --- OnStartup --- func TestRunner_OnStartup_Good(t *testing.T) { c := core.New(core.WithOption("name", "test")) svc := New() svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) r := svc.OnStartup(context.Background()) assert.True(t, r.OK) // Actions should be registered assert.NotNil(t, c.Action("runner.dispatch")) assert.NotNil(t, c.Action("runner.status")) assert.NotNil(t, c.Action("runner.start")) assert.NotNil(t, c.Action("runner.stop")) assert.NotNil(t, c.Action("runner.kill")) assert.NotNil(t, c.Action("runner.poke")) } func TestRunner_OnStartup_Bad_NilCore(t *testing.T) { svc := New() // No ServiceRuntime — OnStartup should panic on s.Core() assert.Panics(t, func() { svc.OnStartup(context.Background()) }) } func TestRunner_OnStartup_Ugly_StartsRunnerLoop(t *testing.T) { c := core.New(core.WithOption("name", "test")) svc := New() svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) svc.OnStartup(context.Background()) assert.NotNil(t, svc.pokeCh, "runner loop should be started") assert.True(t, svc.IsFrozen(), "should be frozen without CORE_AGENT_DISPATCH=1") } // --- OnShutdown --- func TestRunner_OnShutdown_Good(t *testing.T) { svc := New() svc.frozen = false r := svc.OnShutdown(context.Background()) assert.True(t, r.OK) assert.True(t, svc.IsFrozen()) } func TestRunner_OnShutdown_Bad_AlreadyFrozen(t *testing.T) { svc := New() r := svc.OnShutdown(context.Background()) assert.True(t, r.OK) assert.True(t, svc.IsFrozen()) } func TestRunner_OnShutdown_Ugly_DoesNotPanic(t *testing.T) { svc := New() assert.NotPanics(t, func() { svc.OnShutdown(context.Background()) }) } // --- HandleIPCEvents --- func TestRunner_HandleIPCEvents_Good_UnknownMessage(t *testing.T) { c := core.New(core.WithOption("name", "test")) svc := New() svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) // Unknown message type — should not panic r := svc.HandleIPCEvents(c, "unknown") assert.True(t, r.OK) } func TestRunner_HandleIPCEvents_Good_UpdatesMatchingWorkspaceOnly(t *testing.T) { c := core.New(core.WithOption("name", "test")) svc := New() svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) svc.TrackWorkspace("core/go-io/task-1", &WorkspaceStatus{ Status: "running", Agent: "codex", Repo: "go-io", PID: 111, }) svc.TrackWorkspace("core/go-io/task-2", &WorkspaceStatus{ Status: "running", Agent: "codex", Repo: "go-io", PID: 222, }) r := svc.HandleIPCEvents(c, messages.AgentCompleted{ Agent: "codex", Repo: "go-io", Workspace: "core/go-io/task-1", Status: "completed", }) assert.True(t, r.OK) first := svc.workspaces.Get("core/go-io/task-1").Value.(*WorkspaceStatus) second := svc.workspaces.Get("core/go-io/task-2").Value.(*WorkspaceStatus) assert.Equal(t, "completed", first.Status) assert.Equal(t, 0, first.PID) assert.Equal(t, "running", second.Status) assert.Equal(t, 222, second.PID) } func TestRunner_HandleIPCEvents_Good_EmitsQueueDrained(t *testing.T) { c := core.New(core.WithOption("name", "test")) svc := New() svc.ServiceRuntime = core.NewServiceRuntime(c, Options{}) var captured []messages.QueueDrained c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { if ev, ok := msg.(messages.QueueDrained); ok { captured = append(captured, ev) } return core.Result{OK: true} }) r := svc.HandleIPCEvents(c, messages.PokeQueue{}) assert.True(t, r.OK) require.Len(t, captured, 1) assert.Equal(t, 0, captured[0].Completed) } func TestRunner_HydrateWorkspaces_Good_DeepWorkspaceName(t *testing.T) { root := t.TempDir() t.Setenv("CORE_WORKSPACE", root) wsDir := core.JoinPath(root, "workspace", "core", "go-io", "task-5") fs.EnsureDir(wsDir) require.True(t, WriteStatus(wsDir, &WorkspaceStatus{ Status: "running", Agent: "codex", Repo: "go-io", PID: 99999999, }).OK) svc := New() svc.hydrateWorkspaces() r := svc.workspaces.Get("core/go-io/task-5") assert.True(t, r.OK) st := r.Value.(*WorkspaceStatus) assert.Equal(t, "queued", st.Status) assert.Equal(t, "go-io", st.Repo) } // --- WriteStatus / ReadStatusResult --- func TestRunner_WriteReadStatus_Good(t *testing.T) { dir := t.TempDir() st := &WorkspaceStatus{Status: "running", Agent: "codex", Repo: "go-io", PID: 999} require.True(t, WriteStatus(dir, st).OK) got := mustReadStatus(t, dir) assert.Equal(t, "running", got.Status) assert.Equal(t, "codex", got.Agent) assert.Equal(t, 999, got.PID) } func TestRunner_ReadStatus_Bad_NoFile(t *testing.T) { result := ReadStatusResult(t.TempDir()) assert.False(t, result.OK) _, ok := result.Value.(error) assert.True(t, ok) } func TestRunner_WriteReadStatus_Ugly_OverwriteExisting(t *testing.T) { dir := t.TempDir() require.True(t, WriteStatus(dir, &WorkspaceStatus{Status: "running"}).OK) require.True(t, WriteStatus(dir, &WorkspaceStatus{Status: "completed"}).OK) got := mustReadStatus(t, dir) assert.Equal(t, "completed", got.Status) }