diff --git a/services/core/README.md b/services/core/README.md index 557752d..b0e14ab 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -189,3 +189,12 @@ curl localhost:8080/api/tasks curl localhost:8080/api/tasks/{id} curl -X POST localhost:8080/api/tasks/{id}/enqueue ``` + +## 작업 라이프사이클 책임 경계 (Workflow Lifecycle Ownership) + +NomadCode Core의 작업 라이프사이클 관리는 다음과 같은 책임 경계를 따릅니다. + +- **Workflow Service**: 외부 HTTP/Proto-Socket 인터페이스와 상호작용하여 작업 생성(Create), 조회(Get), 목록 조회(List), Enqueue API의 시맨틱(Semantics) 및 정책을 제어합니다. +- **Lifecycle**: 작업 상태 전이(Status Transitions) 및 메타데이터 병합 규칙(Metadata Merge Rules)의 Canonical State 처리를 독점적으로 소유합니다. 상태 변경은 반드시 Lifecycle 헬퍼를 경유해야 합니다. +- **Scheduler Worker**: 작업의 실행 단계만 담당합니다. 비동기 큐에서 작업을 꺼내어 `StartTask`를 호출하고, 실제 Agent/Model 실행을 수행한 후, 성공 시 `CompleteTask`, 실패 시 `FailTask`를 호출하여 실행 단계를 마무리합니다. +- **Provider Projection**: 외부 협업 도구(Plane, Mattermost 등)로 상태나 코멘트를 반영(Projection)하는 동작의 실패가 Core의 정규 작업 상태(Canonical Task State)를 롤백해서는 안 됩니다. 외부 투영 실패는 재시도 가능한 독립적인 부수 효과로 처리됩니다. diff --git a/services/core/internal/scheduler/jobs.go b/services/core/internal/scheduler/jobs.go index 65c1034..27867fd 100644 --- a/services/core/internal/scheduler/jobs.go +++ b/services/core/internal/scheduler/jobs.go @@ -25,6 +25,13 @@ func (TaskJobArgs) Kind() string { return "task_run" } +// TaskLifecycle handles task state transitions and canonical metadata rules. +// +// Responsibility boundaries: +// - Workflow service owns create/list/get/enqueue API semantics. +// - Lifecycle owns all canonical status writes and metadata merge. +// - Scheduler worker owns invoking StartTask, running execution, then invoking CompleteTask or FailTask. +// - Provider projection failure must not roll back canonical task state. type TaskLifecycle interface { StartTask(ctx context.Context, id string) (storage.Task, error) CompleteTask(ctx context.Context, id string, result json.RawMessage) (storage.Task, error) diff --git a/services/core/internal/workflow/service.go b/services/core/internal/workflow/service.go index 1617c1f..4c30b85 100644 --- a/services/core/internal/workflow/service.go +++ b/services/core/internal/workflow/service.go @@ -154,7 +154,11 @@ func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, err } if s.enqueuer == nil { - return queuedTask, errors.New("task enqueuer is not configured") + err := errors.New("task enqueuer is not configured") + 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) + } + return queuedTask, err } if err := s.enqueuer.EnqueueTask(ctx, id); err != nil { if _, failErr := s.lifecycle.FailTask(ctx, id, err.Error()); failErr != nil && s.logger != nil { diff --git a/services/core/internal/workflow/service_test.go b/services/core/internal/workflow/service_test.go index 21fc89c..671b1b0 100644 --- a/services/core/internal/workflow/service_test.go +++ b/services/core/internal/workflow/service_test.go @@ -576,3 +576,46 @@ func TestServiceEnqueueFailureMarksTaskFailedThroughLifecycle(t *testing.T) { t.Errorf("expected agent_run_state failed, got: %v", meta[MetadataKeyAgentRunState]) } } + +func TestServiceEnqueueTaskWithoutEnqueuerMarksTaskFailedThroughLifecycle(t *testing.T) { + ctx := context.Background() + store := newFakeTaskStore() + store.tasks["task-1"] = storage.Task{ + ID: "task-1", + Status: string(StatusPending), + Metadata: json.RawMessage(`{}`), + } + + service := NewService(nil, nil, nil) // enqueuer is nil + service.lifecycle = &Lifecycle{store: store} + + _, err := service.EnqueueTask(ctx, "task-1") + if err == nil { + t.Fatal("expected error, got nil") + } + if err.Error() != "task enqueuer is not configured" { + t.Errorf("expected 'task enqueuer is not configured', 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 != "task enqueuer is not configured" { + t.Errorf("expected error message 'task enqueuer is not configured', 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]) + } + if meta[MetadataKeyStatusReason] != "task enqueuer is not configured" { + t.Errorf("expected status_reason 'task enqueuer is not configured', got: %v", meta[MetadataKeyStatusReason]) + } +}