workflow: enqueue failure 시 Lifecycle를 통해 task를 failed 상태로 전환
- enqueuer가 설정되지 않은 경우에도 FailTask를 호출하여 task 상태를 failed로 표시 - scheduler/jobs.go에 책임 경계 주석 추가 - README에 작업 라이프사이클 책임 경계 문서 추가 - EnqueueTask 실패 시 lifecycle를 통한 상태 전이 테스트 추가
This commit is contained in:
parent
d7aafc47d3
commit
6f383f1213
4 changed files with 64 additions and 1 deletions
|
|
@ -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)를 롤백해서는 안 됩니다. 외부 투영 실패는 재시도 가능한 독립적인 부수 효과로 처리됩니다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue