workflow: enqueue failure 시 Lifecycle를 통해 task를 failed 상태로 전환

- enqueuer가 설정되지 않은 경우에도 FailTask를 호출하여 task 상태를 failed로 표시
- scheduler/jobs.go에 책임 경계 주석 추가
- README에 작업 라이프사이클 책임 경계 문서 추가
- EnqueueTask 실패 시 lifecycle를 통한 상태 전이 테스트 추가
This commit is contained in:
toki 2026-06-01 10:48:29 +09:00
parent d7aafc47d3
commit 6f383f1213
4 changed files with 64 additions and 1 deletions

View file

@ -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)를 롤백해서는 안 됩니다. 외부 투영 실패는 재시도 가능한 독립적인 부수 효과로 처리됩니다.

View file

@ -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)

View file

@ -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 {

View file

@ -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])
}
}