summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-10 17:16:49 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-10 17:16:49 +0000
commit3226af322cb7c7f7a93a91e98651538b1bec54ee (patch)
tree2e1efc4304c97f60ee36e669eb8357145efba691
parent65c7638bbb5819b9997cb6cb1b0fb54f3361347f (diff)
task: promote validTransitions to package-level var; fix ADR
Hoists the map out of ValidTransition so it's not reallocated on every call. Adds missing CANCELLED→QUEUED and BUDGET_EXCEEDED→QUEUED entries to the ADR transition table to match the implemented state machine. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--docs/adr/002-task-state-machine.md4
-rw-r--r--internal/task/task.go37
-rw-r--r--internal/task/task_test.go1
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) {