Files
kandev/apps/backend/internal/office/service/task_starter_test.go
T
2026-05-16 20:07:57 +01:00

446 lines
14 KiB
Go

package service_test
import (
"context"
"fmt"
"sync"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// mockTaskStarter records calls to StartTask and returns a configurable error.
type mockTaskStarter struct {
mu sync.Mutex
calls []startTaskCall
err error
}
type fakeAgentTokenMinter struct {
token string
}
func (f fakeAgentTokenMinter) MintRuntimeJWT(_, _, _, _, _, _ string) (string, error) {
return f.token, nil
}
type startTaskCall struct {
TaskID string
AgentProfileID string
Prompt string
Env map[string]string
}
func (m *mockTaskStarter) StartTask(
_ context.Context, taskID, agentProfileID, _, _ string,
_ string, prompt, _ string, _ bool, _ []v1.MessageAttachment,
) error {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, startTaskCall{
TaskID: taskID,
AgentProfileID: agentProfileID,
Prompt: prompt,
})
return m.err
}
func (m *mockTaskStarter) StartTaskWithEnv(
_ context.Context, taskID, agentProfileID, _, _ string,
_ string, prompt, _ string, _ bool, _ []v1.MessageAttachment, env map[string]string,
) error {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, startTaskCall{
TaskID: taskID,
AgentProfileID: agentProfileID,
Prompt: prompt,
Env: env,
})
return m.err
}
func (m *mockTaskStarter) callCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.calls)
}
func (m *mockTaskStarter) lastCall() startTaskCall {
m.mu.Lock()
defer m.mu.Unlock()
return m.calls[len(m.calls)-1]
}
func TestSchedulerTick_LaunchesAgent(t *testing.T) {
mock := &mockTaskStarter{}
svc := newTestService(t, service.ServiceOptions{TaskStarter: mock})
ctx := context.Background()
agent := &models.AgentInstance{
ID: "launch-agent-1",
WorkspaceID: "ws-1",
Name: "launch-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
// Insert a task so the prompt builder can find it.
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, description, priority, created_at, updated_at)
VALUES ('task-launch-1', 'ws-1', 'Build API', 'Implement endpoint', 'medium', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-launch-1"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
if mock.callCount() != 1 {
t.Fatalf("expected 1 StartTask call, got %d", mock.callCount())
}
call := mock.lastCall()
if call.TaskID != "task-launch-1" {
t.Errorf("task_id = %q, want %q", call.TaskID, "task-launch-1")
}
// After ADR 0005 the agent's row id is the agent_profile_id — they are
// the same column under the merged schema.
if call.AgentProfileID != agent.ID {
t.Errorf("agent_profile_id = %q, want %q", call.AgentProfileID, agent.ID)
}
if call.Prompt == "" {
t.Error("expected non-empty prompt")
}
}
func TestSchedulerTick_LaunchIncludesRuntimeTokenEnv(t *testing.T) {
mock := &mockTaskStarter{}
svc := newTestService(t, service.ServiceOptions{
TaskStarter: mock,
APIBaseURL: "http://localhost:8080/api/v1",
})
svc.SetAgentTokenMinter(fakeAgentTokenMinter{token: "run-token"})
ctx := context.Background()
agent := &models.AgentInstance{
ID: "token-agent-1",
WorkspaceID: "ws-1",
Name: "token-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at)
VALUES ('task-token-1', 'ws-1', 'Build API', 'Implement endpoint', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-token-1","session_id":"sess-token"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
call := mock.lastCall()
if call.Env["KANDEV_API_KEY"] != "run-token" {
t.Fatalf("KANDEV_API_KEY = %q, want run-token", call.Env["KANDEV_API_KEY"])
}
if call.Env["KANDEV_RUN_TOKEN"] != "run-token" {
t.Fatalf("KANDEV_RUN_TOKEN = %q, want run-token", call.Env["KANDEV_RUN_TOKEN"])
}
if call.Env["KANDEV_RUN_ID"] == "" {
t.Fatal("expected KANDEV_RUN_ID")
}
}
func TestSchedulerTick_SnapshotsRunSkills(t *testing.T) {
mock := &mockTaskStarter{}
svc := newTestService(t, service.ServiceOptions{TaskStarter: mock})
ctx := context.Background()
skill := &models.Skill{
WorkspaceID: "ws-1",
Name: "Review",
Slug: "review",
SourceType: service.SkillSourceTypeInline,
Content: "# Review\n",
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create skill: %v", err)
}
agent := &models.AgentInstance{
ID: "skill-agent-1",
WorkspaceID: "ws-1",
Name: "skill-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
DesiredSkills: `["review"]`,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at)
VALUES ('task-skill-1', 'ws-1', 'Review API', 'Review endpoint', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-skill-1"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
runs, err := svc.ListRuns(ctx, "ws-1")
if err != nil {
t.Fatalf("list runs: %v", err)
}
snaps, err := svc.ListRunSkillSnapshotsForTest(ctx, runs[0].ID)
if err != nil {
t.Fatalf("list run skill snapshots: %v", err)
}
if len(snaps) != 1 {
t.Fatalf("snapshots = %d, want 1", len(snaps))
}
if snaps[0].SkillID != skill.ID || snaps[0].ContentHash != skill.ContentHash || snaps[0].Version != skill.Version {
t.Fatalf("snapshot = %#v, skill = %#v", snaps[0], skill)
}
}
func TestSchedulerTick_KeepsRunClaimedUntilAgentCompletes(t *testing.T) {
mock := &mockTaskStarter{}
svc := newTestService(t, service.ServiceOptions{TaskStarter: mock})
svc.SetSyncHandlers(true) // handlers must complete before assertions
ctx := context.Background()
eb := bus.NewMemoryEventBus(logger.Default())
if err := svc.RegisterEventSubscribers(eb); err != nil {
t.Fatalf("register subscribers: %v", err)
}
// Wave G: AgentInstance.ID is the agent_profiles row id; the previous
// AgentProfileID alias is gone, so seed the id directly.
agent := &models.AgentInstance{
ID: "profile-abc",
WorkspaceID: "ws-1",
Name: "lifecycle-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at)
VALUES ('task-life-1', 'ws-1', 'Build API', 'Implement endpoint', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-life-1"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
runs, err := svc.ListRuns(ctx, "ws-1")
if err != nil {
t.Fatalf("list runs: %v", err)
}
if len(runs) != 1 || runs[0].Status != service.RunStatusClaimed {
t.Fatalf("run after launch = %#v, want exactly one claimed run", runs)
}
event := bus.NewEvent(events.AgentCompleted, "test", map[string]string{
"task_id": "task-life-1",
"session_id": "session-1",
})
if err := eb.Publish(ctx, events.AgentCompleted, event); err != nil {
t.Fatalf("publish agent completed: %v", err)
}
runs, err = svc.ListRuns(ctx, "ws-1")
if err != nil {
t.Fatalf("list runs after complete: %v", err)
}
if runs[0].Status != service.RunStatusFinished {
t.Fatalf("run status after completion = %q, want finished", runs[0].Status)
}
}
// TestSchedulerTick_AgentStoppedFinishesRun pins the regression where
// the office fire-and-forget turn-complete handler calls StopAgent (which
// publishes events.AgentStopped, NOT events.AgentCompleted). Without
// subscribing to AgentStopped, the run stays in 'claimed' state forever
// and the same-agent eligibility filter in ClaimNextEligibleRun silently
// drops every subsequent run (comments, status changes, mentions) for
// that agent on that task.
func TestSchedulerTick_AgentStoppedFinishesRun(t *testing.T) {
mock := &mockTaskStarter{}
svc := newTestService(t, service.ServiceOptions{TaskStarter: mock})
svc.SetSyncHandlers(true)
ctx := context.Background()
eb := bus.NewMemoryEventBus(logger.Default())
if err := svc.RegisterEventSubscribers(eb); err != nil {
t.Fatalf("register subscribers: %v", err)
}
agent := &models.AgentInstance{
ID: "profile-stop",
WorkspaceID: "ws-1",
Name: "stop-handler",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, description, created_at, updated_at)
VALUES ('task-stop-1', 'ws-1', 'Stop handler test', 'desc', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-stop-1"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
// AgentStopped (not AgentCompleted) — this is what the office
// fire-and-forget B4 path publishes via lifecycle.StopAgent.
event := bus.NewEvent(events.AgentStopped, "test", map[string]string{
"task_id": "task-stop-1",
"session_id": "session-stop-1",
})
if err := eb.Publish(ctx, events.AgentStopped, event); err != nil {
t.Fatalf("publish agent stopped: %v", err)
}
runs, err := svc.ListRuns(ctx, "ws-1")
if err != nil {
t.Fatalf("list runs: %v", err)
}
if runs[0].Status != service.RunStatusFinished {
t.Fatalf("run status after AgentStopped = %q, want finished — comments will silently fail until this is fixed",
runs[0].Status)
}
}
func TestSchedulerTick_RecoversStaleClaimedRun(t *testing.T) {
svc := newTestService(t, service.ServiceOptions{})
ctx := context.Background()
agent := &models.AgentInstance{
WorkspaceID: "ws-1",
Name: "stale-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `
INSERT INTO runs (
id, agent_profile_id, reason, payload, status, coalesced_count,
context_snapshot, retry_count, requested_at, claimed_at
) VALUES (
'run-stale-1', ?, 'task_assigned', '{"task_id":"task-stale-1"}',
'claimed', 1, '{}', 0, datetime('now', '-35 minutes'), datetime('now', '-35 minutes')
)
`, agent.ID)
service.RunSchedulerTick(svc, ctx)
runs, err := svc.ListRuns(ctx, "ws-1")
if err != nil {
t.Fatalf("list runs: %v", err)
}
if len(runs) != 1 {
t.Fatalf("run count = %d, want 1", len(runs))
}
if runs[0].Status != service.RunStatusQueued {
t.Fatalf("run status = %q, want queued", runs[0].Status)
}
}
func TestSchedulerTick_StartTaskError_TriggersRetry(t *testing.T) {
mock := &mockTaskStarter{err: fmt.Errorf("container start failed")}
svc := newTestService(t, service.ServiceOptions{TaskStarter: mock})
ctx := context.Background()
agent := &models.AgentInstance{
WorkspaceID: "ws-1",
Name: "fail-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES ('task-fail-1', 'ws-1', 'Failing Task', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-fail-1"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
if mock.callCount() != 1 {
t.Fatalf("expected 1 StartTask call, got %d", mock.callCount())
}
// The run should have been retried (back in the queue with retry_count=1).
// Advance the retry time so it becomes claimable.
svc.ExecSQL(t, `UPDATE runs SET scheduled_retry_at = datetime('now', '-1 second')`)
next, err := svc.ClaimNextRun(ctx)
if err != nil {
t.Fatalf("claim after failure: %v", err)
}
if next == nil {
t.Fatal("expected retried run to be claimable")
}
if next.RetryCount != 1 {
t.Errorf("retry_count = %d, want 1", next.RetryCount)
}
}
func TestSchedulerTick_NoTaskStarter_FinishesWithoutError(t *testing.T) {
svc := newTestService(t)
ctx := context.Background()
// Intentionally do NOT call SetTaskStarter.
agent := &models.AgentInstance{
WorkspaceID: "ws-1",
Name: "noop-worker",
Role: models.AgentRoleWorker,
Status: models.AgentStatusIdle,
ExecutorPreference: `{"type":"worktree"}`,
}
if err := svc.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
svc.ExecSQL(t, `INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES ('task-noop-1', 'ws-1', 'NoOp Task', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
if err := svc.QueueRun(ctx, agent.ID, service.RunReasonTaskAssigned, `{"task_id":"task-noop-1"}`, ""); err != nil {
t.Fatalf("queue: %v", err)
}
service.RunSchedulerTick(svc, ctx)
// Run should be consumed (finished), queue empty.
next, err := svc.ClaimNextRun(ctx)
if err != nil {
t.Fatalf("claim: %v", err)
}
if next != nil {
t.Error("expected queue to be empty after tick with no task starter")
}
}