diff options
| -rw-r--r-- | docs/adr/002-task-state-machine.md | 4 | ||||
| -rw-r--r-- | internal/task/task.go | 37 | ||||
| -rw-r--r-- | internal/task/task_test.go | 1 |
3 files changed, 25 insertions, 17 deletions
diff --git a/docs/adr/002-task-state-machine.md b/docs/adr/002-task-state-machine.md index 1d41619..310c337 100644 --- a/docs/adr/002-task-state-machine.md +++ b/docs/adr/002-task-state-machine.md @@ -23,7 +23,7 @@ execution (subprocess), user interaction (review, Q&A), retries, and cancellatio | `BUDGET_EXCEEDED` | Exceeded `max_budget_usd` (terminal) | | `BLOCKED` | Agent paused and wrote a question file; awaiting user answer | -Terminal states with no outgoing transitions: `COMPLETED`, `CANCELLED`, `BUDGET_EXCEEDED`. +True terminal state (no outgoing transitions): `COMPLETED`. All other non-success states (`CANCELLED`, `FAILED`, `TIMED_OUT`, `BUDGET_EXCEEDED`) may transition back to `QUEUED` to restart or retry. ## State Transition Diagram @@ -77,6 +77,8 @@ Terminal states with no outgoing transitions: `COMPLETED`, `CANCELLED`, `BUDGET_ | `READY` | `PENDING` | `POST /api/tasks/{id}/reject` (with optional comment) | | `FAILED` | `QUEUED` | Retry (manual re-run via `POST /api/tasks/{id}/run`) | | `TIMED_OUT` | `QUEUED` | `POST /api/tasks/{id}/resume` (resumes with session ID) | +| `CANCELLED` | `QUEUED` | Restart (manual re-run via `POST /api/tasks/{id}/run`) | +| `BUDGET_EXCEEDED` | `QUEUED` | Retry (manual re-run via `POST /api/tasks/{id}/run`) | | `BLOCKED` | `QUEUED` | `POST /api/tasks/{id}/answer` (resumes with user answer) | | `BLOCKED` | `READY` | All subtasks reached `COMPLETED` (parent task unblocked by subtask completion watcher) | diff --git a/internal/task/task.go b/internal/task/task.go index c0aa036..9968b15 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -52,10 +52,10 @@ type Task struct { ID string `yaml:"id" json:"id"` ParentTaskID string `yaml:"parent_task_id" json:"parent_task_id"` Name string `yaml:"name" json:"name"` - Description string `yaml:"description" json:"description"` - Agent AgentConfig `yaml:"agent" json:"agent"` - Timeout Duration `yaml:"timeout" json:"timeout"` - Retry RetryConfig `yaml:"retry" json:"retry"` + Description string `yaml:"description" json:"description"` + Agent AgentConfig `yaml:"agent" json:"agent"` + Timeout Duration `yaml:"timeout" json:"timeout"` + Retry RetryConfig `yaml:"retry" json:"retry"` Priority Priority `yaml:"priority" json:"priority"` Tags []string `yaml:"tags" json:"tags"` DependsOn []string `yaml:"depends_on" json:"depends_on"` @@ -93,20 +93,25 @@ type BatchFile struct { Tasks []Task `yaml:"tasks"` } +// validTransitions maps each state to the set of states it may transition into. +// Terminal state COMPLETED has no outgoing edges. +// CANCELLED, FAILED, TIMED_OUT, and BUDGET_EXCEEDED all allow re-entry at QUEUED +// (restart or retry). +var validTransitions = map[State][]State{ + StatePending: {StateQueued, StateCancelled}, + StateQueued: {StateRunning, StateCancelled}, + StateRunning: {StateReady, StateCompleted, StateFailed, StateTimedOut, StateCancelled, StateBudgetExceeded, StateBlocked}, + StateReady: {StateCompleted, StatePending}, + StateFailed: {StateQueued}, // retry + StateTimedOut: {StateQueued}, // retry or resume + StateCancelled: {StateQueued}, // restart + StateBudgetExceeded: {StateQueued}, // retry + StateBlocked: {StateQueued, StateReady}, +} + // ValidTransition returns true if moving from the current state to next is allowed. func ValidTransition(from, to State) bool { - transitions := map[State][]State{ - StatePending: {StateQueued, StateCancelled}, - StateQueued: {StateRunning, StateCancelled}, - StateRunning: {StateReady, StateCompleted, StateFailed, StateTimedOut, StateCancelled, StateBudgetExceeded, StateBlocked}, - StateReady: {StateCompleted, StatePending}, - StateFailed: {StateQueued}, // retry - StateTimedOut: {StateQueued}, // retry - StateCancelled: {StateQueued}, // restart - StateBudgetExceeded: {StateQueued}, // retry - StateBlocked: {StateQueued, StateReady}, // answer received → re-queue as resume execution - } - for _, allowed := range transitions[from] { + for _, allowed := range validTransitions[from] { if allowed == to { return true } diff --git a/internal/task/task_test.go b/internal/task/task_test.go index 637baf5..9873084 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -26,6 +26,7 @@ func TestValidTransition_AllowedTransitions(t *testing.T) { {"blocked to queued (answer resume)", StateBlocked, StateQueued}, {"blocked to ready (parent unblocked by subtasks)", StateBlocked, StateReady}, {"budget exceeded to queued (retry)", StateBudgetExceeded, StateQueued}, + {"cancelled to queued (restart)", StateCancelled, StateQueued}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { |
