refactor(workflow): task lifecycle를 별도 레이어로 분리하고 interface 추상화한다
- Lifecycle에 taskStore interface를 도입해 storage 의존성 추상화 - validTaskStatus, terminalTaskStatus, canEnqueueStatus helper 함수로 상태 검사 로직 분리 - QueueTask 메서드를 Lifecycle로 이동해 상태 전이 통합 관리 - Service에 lifecycle 필드 추가하고 EnqueueTask에서 lifecycle 활용 - lifecycle 전이 검증 및 enqueue 흐름 테스트 추가
This commit is contained in:
parent
18853a5d31
commit
d7aafc47d3
3 changed files with 468 additions and 24 deletions
|
|
@ -10,8 +10,18 @@ import (
|
||||||
"github.com/nomadcode/nomadcode-core/internal/storage"
|
"github.com/nomadcode/nomadcode-core/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type taskStore interface {
|
||||||
|
CreateTask(context.Context, storage.CreateTaskInput) (storage.Task, error)
|
||||||
|
GetTask(context.Context, string) (storage.Task, error)
|
||||||
|
ListTasks(context.Context, int32) ([]storage.Task, error)
|
||||||
|
UpdateMetadata(context.Context, string, json.RawMessage) (storage.Task, error)
|
||||||
|
UpdateStatus(context.Context, string, string) (storage.Task, error)
|
||||||
|
CompleteTask(context.Context, string, json.RawMessage) (storage.Task, error)
|
||||||
|
FailTask(context.Context, string, string) (storage.Task, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Lifecycle struct {
|
type Lifecycle struct {
|
||||||
store *storage.Store
|
store taskStore
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,10 +32,39 @@ func NewLifecycle(store *storage.Store, logger *slog.Logger) *Lifecycle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validTaskStatus(status TaskStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case StatusPending, StatusQueued, StatusRunning, StatusCompleted, StatusFailed, StatusCanceled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminalTaskStatus(status TaskStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case StatusCompleted, StatusFailed, StatusCanceled:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEnqueueStatus(status TaskStatus) bool {
|
||||||
|
switch status {
|
||||||
|
case StatusPending, StatusFailed:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func canTransition(from, to TaskStatus) bool {
|
func canTransition(from, to TaskStatus) bool {
|
||||||
switch from {
|
switch from {
|
||||||
case StatusPending, StatusQueued, StatusFailed:
|
case StatusPending, StatusFailed:
|
||||||
return to == StatusRunning
|
return to == StatusRunning
|
||||||
|
case StatusQueued:
|
||||||
|
return to == StatusRunning || to == StatusFailed || to == StatusCanceled
|
||||||
case StatusRunning:
|
case StatusRunning:
|
||||||
return to == StatusCompleted || to == StatusFailed || to == StatusCanceled
|
return to == StatusCompleted || to == StatusFailed || to == StatusCanceled
|
||||||
default:
|
default:
|
||||||
|
|
@ -78,6 +117,19 @@ func mergeTaskMetadata(current json.RawMessage, updates map[string]any) (json.Ra
|
||||||
return json.Marshal(currentMap)
|
return json.Marshal(currentMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Lifecycle) QueueTask(ctx context.Context, id string) (storage.Task, error) {
|
||||||
|
task, err := l.store.GetTask(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return storage.Task{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canEnqueueStatus(TaskStatus(task.Status)) {
|
||||||
|
return storage.Task{}, ErrTaskCannotBeEnqueued
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.store.UpdateStatus(ctx, id, string(StatusQueued))
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Lifecycle) StartTask(ctx context.Context, id string) (storage.Task, error) {
|
func (l *Lifecycle) StartTask(ctx context.Context, id string) (storage.Task, error) {
|
||||||
task, err := l.store.GetTask(ctx, id)
|
task, err := l.store.GetTask(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,18 @@ type TaskEnqueuer interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
enqueuer TaskEnqueuer
|
enqueuer TaskEnqueuer
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
lifecycle *Lifecycle
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store *storage.Store, enqueuer TaskEnqueuer, logger *slog.Logger) *Service {
|
func NewService(store *storage.Store, enqueuer TaskEnqueuer, logger *slog.Logger) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
store: store,
|
store: store,
|
||||||
enqueuer: enqueuer,
|
enqueuer: enqueuer,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
lifecycle: NewLifecycle(store, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,15 +148,7 @@ func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata js
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, error) {
|
func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, error) {
|
||||||
task, err := s.store.GetTask(ctx, id)
|
queuedTask, err := s.lifecycle.QueueTask(ctx, id)
|
||||||
if err != nil {
|
|
||||||
return storage.Task{}, err
|
|
||||||
}
|
|
||||||
if !canEnqueue(task.Status) {
|
|
||||||
return storage.Task{}, ErrTaskCannotBeEnqueued
|
|
||||||
}
|
|
||||||
|
|
||||||
queuedTask, err := s.store.UpdateStatus(ctx, id, string(StatusQueued))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return storage.Task{}, err
|
return storage.Task{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +157,7 @@ func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, err
|
||||||
return queuedTask, errors.New("task enqueuer is not configured")
|
return queuedTask, errors.New("task enqueuer is not configured")
|
||||||
}
|
}
|
||||||
if err := s.enqueuer.EnqueueTask(ctx, id); err != nil {
|
if err := s.enqueuer.EnqueueTask(ctx, id); err != nil {
|
||||||
if _, failErr := s.store.FailTask(ctx, id, err.Error()); failErr != nil && s.logger != nil {
|
if _, failErr := s.lifecycle.FailTask(ctx, id, err.Error()); failErr != nil && s.logger != nil {
|
||||||
s.logger.Error("failed to mark task enqueue error", "task_id", id, "error", failErr)
|
s.logger.Error("failed to mark task enqueue error", "task_id", id, "error", failErr)
|
||||||
}
|
}
|
||||||
return queuedTask, err
|
return queuedTask, err
|
||||||
|
|
@ -173,10 +167,5 @@ func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func canEnqueue(status string) bool {
|
func canEnqueue(status string) bool {
|
||||||
switch TaskStatus(status) {
|
return canEnqueueStatus(TaskStatus(status))
|
||||||
case StatusPending, StatusFailed:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package workflow
|
package workflow
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nomadcode/nomadcode-core/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeExternalRefDefaultsMetadata(t *testing.T) {
|
func TestNormalizeExternalRefDefaultsMetadata(t *testing.T) {
|
||||||
|
|
@ -173,3 +177,402 @@ func TestCanTransitionAllowsRetryFromFailedToRunning(t *testing.T) {
|
||||||
t.Error("expected canTransition(StatusFailed, StatusRunning) to be true, got false")
|
t.Error("expected canTransition(StatusFailed, StatusRunning) to be true, got false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskStatusHelpersClassifyLifecycleStates(t *testing.T) {
|
||||||
|
// 1. validTaskStatus
|
||||||
|
validStatuses := []TaskStatus{StatusPending, StatusQueued, StatusRunning, StatusCompleted, StatusFailed, StatusCanceled}
|
||||||
|
for _, status := range validStatuses {
|
||||||
|
if !validTaskStatus(status) {
|
||||||
|
t.Errorf("expected %s to be valid", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validTaskStatus(TaskStatus("invalid")) {
|
||||||
|
t.Errorf("expected 'invalid' status to be invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. terminalTaskStatus
|
||||||
|
terminalStatuses := map[TaskStatus]bool{
|
||||||
|
StatusPending: false,
|
||||||
|
StatusQueued: false,
|
||||||
|
StatusRunning: false,
|
||||||
|
StatusCompleted: true,
|
||||||
|
StatusFailed: true,
|
||||||
|
StatusCanceled: true,
|
||||||
|
}
|
||||||
|
for status, expected := range terminalStatuses {
|
||||||
|
got := terminalTaskStatus(status)
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("terminalTaskStatus(%s) = %v; want %v", status, got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. canEnqueueStatus
|
||||||
|
enqueueStatuses := map[TaskStatus]bool{
|
||||||
|
StatusPending: true,
|
||||||
|
StatusQueued: false,
|
||||||
|
StatusRunning: false,
|
||||||
|
StatusCompleted: false,
|
||||||
|
StatusFailed: true,
|
||||||
|
StatusCanceled: false,
|
||||||
|
}
|
||||||
|
for status, expected := range enqueueStatuses {
|
||||||
|
got := canEnqueueStatus(status)
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("canEnqueueStatus(%s) = %v; want %v", status, got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTaskStore struct {
|
||||||
|
tasks map[string]storage.Task
|
||||||
|
operations []string
|
||||||
|
metadataCalls []metadataCall
|
||||||
|
statusCalls []statusCall
|
||||||
|
completeCalls []completeCall
|
||||||
|
failCalls []failCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type metadataCall struct {
|
||||||
|
id string
|
||||||
|
metadata json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusCall struct {
|
||||||
|
id string
|
||||||
|
status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type completeCall struct {
|
||||||
|
id string
|
||||||
|
result json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type failCall struct {
|
||||||
|
id string
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeTaskStore() *fakeTaskStore {
|
||||||
|
return &fakeTaskStore{
|
||||||
|
tasks: make(map[string]storage.Task),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) CreateTask(ctx context.Context, input storage.CreateTaskInput) (storage.Task, error) {
|
||||||
|
return storage.Task{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) GetTask(ctx context.Context, id string) (storage.Task, error) {
|
||||||
|
t, ok := f.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return storage.Task{}, errors.New("task not found")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) ListTasks(ctx context.Context, limit int32) ([]storage.Task, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) UpdateMetadata(ctx context.Context, id string, metadata json.RawMessage) (storage.Task, error) {
|
||||||
|
f.operations = append(f.operations, "metadata:"+id)
|
||||||
|
f.metadataCalls = append(f.metadataCalls, metadataCall{id: id, metadata: metadata})
|
||||||
|
t, ok := f.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return storage.Task{}, errors.New("task not found")
|
||||||
|
}
|
||||||
|
t.Metadata = metadata
|
||||||
|
f.tasks[id] = t
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) UpdateStatus(ctx context.Context, id, status string) (storage.Task, error) {
|
||||||
|
f.operations = append(f.operations, "status:"+id+":"+status)
|
||||||
|
f.statusCalls = append(f.statusCalls, statusCall{id: id, status: status})
|
||||||
|
t, ok := f.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return storage.Task{}, errors.New("task not found")
|
||||||
|
}
|
||||||
|
t.Status = status
|
||||||
|
f.tasks[id] = t
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) CompleteTask(ctx context.Context, id string, result json.RawMessage) (storage.Task, error) {
|
||||||
|
f.operations = append(f.operations, "complete:"+id)
|
||||||
|
f.completeCalls = append(f.completeCalls, completeCall{id: id, result: result})
|
||||||
|
t, ok := f.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return storage.Task{}, errors.New("task not found")
|
||||||
|
}
|
||||||
|
t.Status = string(StatusCompleted)
|
||||||
|
t.Result = result
|
||||||
|
f.tasks[id] = t
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskStore) FailTask(ctx context.Context, id, message string) (storage.Task, error) {
|
||||||
|
f.operations = append(f.operations, "fail:"+id)
|
||||||
|
f.failCalls = append(f.failCalls, failCall{id: id, message: message})
|
||||||
|
t, ok := f.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return storage.Task{}, errors.New("task not found")
|
||||||
|
}
|
||||||
|
t.Status = string(StatusFailed)
|
||||||
|
t.Error = &message
|
||||||
|
f.tasks[id] = t
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLifecycleStartCompleteFailTransitionsWithStore(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Pending or Queued task can start and returns running.
|
||||||
|
t.Run("start task from pending", func(t *testing.T) {
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-1"] = storage.Task{
|
||||||
|
ID: "task-1",
|
||||||
|
Status: string(StatusPending),
|
||||||
|
Metadata: json.RawMessage(`{}`),
|
||||||
|
}
|
||||||
|
l := &Lifecycle{store: store}
|
||||||
|
|
||||||
|
task, err := l.StartTask(ctx, "task-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != string(StatusRunning) {
|
||||||
|
t.Errorf("expected status running, got: %s", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata gets agent_run_state, last_heartbeat_at, and attempt=1 on start
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(task.Metadata, &meta); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyAgentRunState] != "running" {
|
||||||
|
t.Errorf("expected agent_run_state running, got: %v", meta[MetadataKeyAgentRunState])
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyAttempt] != float64(1) {
|
||||||
|
t.Errorf("expected attempt 1, got: %v", meta[MetadataKeyAttempt])
|
||||||
|
}
|
||||||
|
if _, ok := meta[MetadataKeyLastHeartbeat]; !ok {
|
||||||
|
t.Errorf("expected last_heartbeat_at to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify store calls in order: UpdateMetadata, then UpdateStatus using global operations log
|
||||||
|
if len(store.operations) != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 store operations, got: %d", len(store.operations))
|
||||||
|
}
|
||||||
|
if store.operations[0] != "metadata:task-1" {
|
||||||
|
t.Errorf("expected first operation to be metadata:task-1, got: %s", store.operations[0])
|
||||||
|
}
|
||||||
|
if store.operations[1] != "status:task-1:running" {
|
||||||
|
t.Errorf("expected second operation to be status:task-1:running, got: %s", store.operations[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("start task from queued (increments attempt)", func(t *testing.T) {
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-2"] = storage.Task{
|
||||||
|
ID: "task-2",
|
||||||
|
Status: string(StatusQueued),
|
||||||
|
Metadata: json.RawMessage(`{"attempt":1}`),
|
||||||
|
}
|
||||||
|
l := &Lifecycle{store: store}
|
||||||
|
|
||||||
|
task, err := l.StartTask(ctx, "task-2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != string(StatusRunning) {
|
||||||
|
t.Errorf("expected status running, got: %s", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(task.Metadata, &meta); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyAttempt] != float64(2) {
|
||||||
|
t.Errorf("expected attempt 2, got: %v", meta[MetadataKeyAttempt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Running task can complete and returns completed.
|
||||||
|
t.Run("complete running task", func(t *testing.T) {
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-3"] = storage.Task{
|
||||||
|
ID: "task-3",
|
||||||
|
Status: string(StatusRunning),
|
||||||
|
Metadata: json.RawMessage(`{"attempt":1,"agent_run_state":"running"}`),
|
||||||
|
}
|
||||||
|
l := &Lifecycle{store: store}
|
||||||
|
|
||||||
|
result := json.RawMessage(`{"summary":"success detail"}`)
|
||||||
|
task, err := l.CompleteTask(ctx, "task-3", result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != string(StatusCompleted) {
|
||||||
|
t.Errorf("expected status completed, got: %s", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(task.Metadata, &meta); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyAgentRunState] != "completed" {
|
||||||
|
t.Errorf("expected agent_run_state completed, got: %v", meta[MetadataKeyAgentRunState])
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyStatusReason] != "success detail" {
|
||||||
|
t.Errorf("expected status_reason 'success detail', got: %v", meta[MetadataKeyStatusReason])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify store calls in order: UpdateMetadata, then CompleteTask using global operations log
|
||||||
|
if len(store.operations) != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 store operations, got: %d", len(store.operations))
|
||||||
|
}
|
||||||
|
if store.operations[0] != "metadata:task-3" {
|
||||||
|
t.Errorf("expected first operation to be metadata:task-3, got: %s", store.operations[0])
|
||||||
|
}
|
||||||
|
if store.operations[1] != "complete:task-3" {
|
||||||
|
t.Errorf("expected second operation to be complete:task-3, got: %s", store.operations[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Running task can fail and returns failed.
|
||||||
|
t.Run("fail running task", func(t *testing.T) {
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-4"] = storage.Task{
|
||||||
|
ID: "task-4",
|
||||||
|
Status: string(StatusRunning),
|
||||||
|
Metadata: json.RawMessage(`{"attempt":1,"agent_run_state":"running"}`),
|
||||||
|
}
|
||||||
|
l := &Lifecycle{store: store}
|
||||||
|
|
||||||
|
task, err := l.FailTask(ctx, "task-4", "something went wrong")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != string(StatusFailed) {
|
||||||
|
t.Errorf("expected status failed, got: %s", task.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(task.Metadata, &meta); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyAgentRunState] != "failed" {
|
||||||
|
t.Errorf("expected agent_run_state failed, got: %v", meta[MetadataKeyAgentRunState])
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyStatusReason] != "something went wrong" {
|
||||||
|
t.Errorf("expected status_reason 'something went wrong', got: %v", meta[MetadataKeyStatusReason])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify store calls in order: UpdateMetadata, then FailTask using global operations log
|
||||||
|
if len(store.operations) != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 store operations, got: %d", len(store.operations))
|
||||||
|
}
|
||||||
|
if store.operations[0] != "metadata:task-4" {
|
||||||
|
t.Errorf("expected first operation to be metadata:task-4, got: %s", store.operations[0])
|
||||||
|
}
|
||||||
|
if store.operations[1] != "fail:task-4" {
|
||||||
|
t.Errorf("expected second operation to be fail:task-4, got: %s", store.operations[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Completed task cannot start.
|
||||||
|
t.Run("cannot start completed task", func(t *testing.T) {
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-5"] = storage.Task{
|
||||||
|
ID: "task-5",
|
||||||
|
Status: string(StatusCompleted),
|
||||||
|
Metadata: json.RawMessage(`{}`),
|
||||||
|
}
|
||||||
|
l := &Lifecycle{store: store}
|
||||||
|
|
||||||
|
_, err := l.StartTask(ctx, "task-5")
|
||||||
|
if err != ErrInvalidTaskTransition {
|
||||||
|
t.Errorf("expected ErrInvalidTaskTransition, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTaskEnqueuer struct {
|
||||||
|
err error
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskEnqueuer) EnqueueTask(ctx context.Context, taskID string) error {
|
||||||
|
f.calls = append(f.calls, taskID)
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceEnqueueTaskUsesLifecycleQueueTransition(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-1"] = storage.Task{
|
||||||
|
ID: "task-1",
|
||||||
|
Status: string(StatusPending),
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueuer := &fakeTaskEnqueuer{}
|
||||||
|
service := NewService(nil, enqueuer, nil)
|
||||||
|
service.lifecycle = &Lifecycle{store: store}
|
||||||
|
|
||||||
|
task, err := service.EnqueueTask(ctx, "task-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != string(StatusQueued) {
|
||||||
|
t.Errorf("expected status queued, got: %s", task.Status)
|
||||||
|
}
|
||||||
|
if len(enqueuer.calls) != 1 || enqueuer.calls[0] != "task-1" {
|
||||||
|
t.Errorf("expected enqueuer to be called with task-1, got: %v", enqueuer.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(store.statusCalls) != 1 || store.statusCalls[0].status != string(StatusQueued) {
|
||||||
|
t.Errorf("expected status transition to queued, got: %v", store.statusCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceEnqueueFailureMarksTaskFailedThroughLifecycle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := newFakeTaskStore()
|
||||||
|
store.tasks["task-1"] = storage.Task{
|
||||||
|
ID: "task-1",
|
||||||
|
Status: string(StatusPending),
|
||||||
|
Metadata: json.RawMessage(`{}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueuer := &fakeTaskEnqueuer{err: errors.New("enqueue error")}
|
||||||
|
service := NewService(nil, enqueuer, nil)
|
||||||
|
service.lifecycle = &Lifecycle{store: store}
|
||||||
|
|
||||||
|
_, err := service.EnqueueTask(ctx, "task-1")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if err.Error() != "enqueue error" {
|
||||||
|
t.Errorf("expected 'enqueue error', got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := store.GetTask(ctx, "task-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if task.Status != string(StatusFailed) {
|
||||||
|
t.Errorf("expected task to be marked failed, got status: %s", task.Status)
|
||||||
|
}
|
||||||
|
if task.Error == nil || *task.Error != "enqueue error" {
|
||||||
|
t.Errorf("expected error message 'enqueue error', got: %v", task.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta map[string]any
|
||||||
|
if err := json.Unmarshal(task.Metadata, &meta); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
if meta[MetadataKeyAgentRunState] != "failed" {
|
||||||
|
t.Errorf("expected agent_run_state failed, got: %v", meta[MetadataKeyAgentRunState])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue