Auto-block tickets with unsatisfied dependencies (phased development)
Done
Task
High
Description
## Problem
Currently, agents can be assigned tickets with unsatisfied dependencies. For Epic #79, this meant all 9 phases were worked in parallel, creating conflicting PRs that touch the same files.
## Solution
Implement automatic dependency blocking so that tickets cannot be assigned until their dependencies are satisfied.
## Requirements
### 1. Add dependency checking to Ticket model
**New methods in `app/models/ticket.rb`:**
```ruby
# Check if explicit dependencies (dependencies array) are satisfied
# A dependency is satisfied if the ticket is done OR cancelled
def explicit_dependencies_satisfied?
return true if dependencies.blank?
dependencies.all? do |dep_id|
dep = Ticket.find_by(id: dep_id)
dep&.done? || dep&.cancelled?
end
end
# For subtasks, check if previous siblings (by ID) are done/cancelled
# This enables sequential phased development without manually setting dependencies
def implicit_dependency_satisfied?
return true unless parent_id.present?
previous_siblings = Ticket.where(parent_id: parent_id)
.where("id < ?", id)
.where.not(status: ["done", "cancelled"])
previous_siblings.none?
end
# Combined check - both explicit and implicit must be satisfied
def dependencies_satisfied?
explicit_dependencies_satisfied? && implicit_dependency_satisfied?
end
# Return array of blocking ticket IDs (for error messages)
def blocking_dependencies
blocking = []
# Explicit dependencies
dependencies.each do |dep_id|
dep = Ticket.find_by(id: dep_id)
blocking << dep_id unless dep&.done? || dep&.cancelled?
end
# Implicit dependency (previous sibling subtask)
if parent_id.present?
previous_sibling = Ticket.where(parent_id: parent_id)
.where("id < ?", id)
.where.not(status: ["done", "cancelled"])
.order(id: :desc)
.first
blocking << previous_sibling.id if previous_sibling
end
blocking.uniq
end
```
### 2. Auto-block on ticket creation
Add callback to `app/models/ticket.rb`:
```ruby
after_create :auto_block_if_dependencies_unsatisfied, if: :needs_dependency_check?
private
def needs_dependency_check?
parent_id.present? || dependencies.present?
end
def auto_block_if_dependencies_unsatisfied
return if dependencies_satisfied?
block!("Waiting for dependencies: #{blocking_dependencies.join(', ')}")
end
```
### 3. Prevent assignment in MCP controller
Update `app/controllers/api/v1/mcp_controller.rb` in `handle_assign_ticket`:
After line 455 (the claimable_statuses check), add:
```ruby
# NEW: Check dependencies before allowing assignment
unless ticket.dependencies_satisfied?
blocking = ticket.blocking_dependencies
return {
error: "Ticket has unsatisfied dependencies",
ticket_id: ticket.id,
blocking: blocking,
message: "Cannot assign - waiting for tickets #{blocking.join(', ')}"
}
end
```
### 4. Auto-unblock dependents when ticket completes
Add callback to `app/models/ticket.rb`:
```ruby
after_update_commit :unblock_dependent_tickets, if: :completed_now?
private
def completed_now?
saved_change_to_status?(to: ["done", "cancelled"])
end
def unblock_dependent_tickets
# Find tickets that have this ticket as an explicit dependency
explicit_dependents = Ticket.where("dependencies @> ?", id.to_s)
.where(status: :blocked)
# Find subtask siblings that implicitly depend on this one (next in ID order)
implicit_dependents = Ticket.where(parent_id: parent_id)
.where("id > ?", id)
.where(status: :blocked)
.select { |t| t.dependencies_satisfied? }
(explicit_dependents + implicit_dependents).each do |dependent|
dependent.reopen! if dependent.blocked? && dependent.dependencies_satisfied?
end
end
```
### 5. Update assignments_controller (if used)
Apply same dependency check to `app/controllers/api/v1/assignments_controller.rb`:
After line 12 (before the `if ticket` block starts), add:
```ruby
# Skip tickets with unsatisfied dependencies
next unless ticket.dependencies_satisfied?
```
### 6. Add tests to `spec/models/ticket_spec.rb`
```ruby
describe "dependency blocking" do
let(:parent) { create(:ticket, :epic) }
let(:phase1) { create(:ticket, :subtask, parent: parent) }
let(:phase2) { create(:ticket, :subtask, parent: parent) }
let(:phase3) { create(:ticket, :subtask, parent: parent) }
it "auto-blocks subtasks by ID order" do
expect(phase1.status).to eq "backlog" # First subtask not blocked
expect(phase2.reload.status).to eq "blocked" # Waiting for phase1
expect(phase3.reload.status).to eq "blocked" # Waiting for phase2
end
it "unblocks phase2 when phase1 is done" do
phase1.approve!
expect(phase2.reload.status).to eq "backlog" # Unblocked
expect(phase3.reload.status).to eq "blocked" # Still waiting
end
it "treats cancelled as satisfied" do
phase1.cancel!
expect(phase2.reload.status).to eq "backlog" # Unblocked
end
it "respects explicit dependencies" do
ticket = create(:ticket, dependencies: [phase1.id])
expect(ticket.status).to eq "blocked"
phase1.approve!
expect(ticket.reload.status).to eq "backlog"
end
end
```
## Acceptance Criteria
1. Subtasks are auto-blocked if previous sibling (by ID) isn't done/cancelled
2. Explicit `dependencies` array is respected
3. Cancelled tickets count as satisfied dependencies
4. `assign_ticket` MCP tool returns error for tickets with unsatisfied dependencies
5. When a ticket completes, dependent tickets are automatically unblocked
6. All new tests pass
## Files to Modify
- `app/models/ticket.rb` - Add methods and callbacks
- `app/controllers/api/v1/mcp_controller.rb` - Add check in `handle_assign_ticket`
- `app/controllers/api/v1/assignments_controller.rb` - Skip blocked tickets
- `spec/models/ticket_spec.rb` - Add tests
Ticket Stats
Status:
Done
Priority:
High
Type:
Task
Comments
0 commentsAdd a Comment
No Subtasks Yet
Break down this ticket into smaller, manageable subtasks