fix: allow queueing behind active blockers

This commit is contained in:
Keisuke Hirata 2026-06-20 14:34:00 +09:00
parent b4cb9fbc41
commit 356d06ef58
No known key found for this signature in database
5 changed files with 155 additions and 2 deletions

View File

@ -0,0 +1,21 @@
{
"version": 1,
"relations": [
{
"ticket_id": "00001KVHQDS6B",
"kind": "related",
"target": "00001KVHKWNQA",
"note": "Observed prerequisite Ticket already queued/in progress should allow dependent queueing.",
"author": "yoi ticket",
"at": "2026-06-20T05:19:32Z"
},
{
"ticket_id": "00001KVHQDS6B",
"kind": "related",
"target": "00001KVHKWNQS",
"note": "Dependent Ticket hit backend conflict despite Panel queue UI allowing it.",
"author": "yoi ticket",
"at": "2026-06-20T05:19:32Z"
}
]
}

View File

@ -0,0 +1,55 @@
---
title: 'Panel Queue action should allow ready Tickets whose blockers are already queued or in progress'
state: 'done'
created_at: '2026-06-20T05:18:00Z'
updated_at: '2026-06-20T05:19:32Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['ticket', 'panel', 'queue', 'dependency', 'blocker', 'orchestrator']
---
## Background
Panel ViewModel already treats a ready Ticket as queueable when all blocking relations point at Tickets that are already `queued` or `inprogress`. That matches the intended workflow: once prerequisite work is queued/accepted by Orchestrator, dependent ready Tickets should also be queueable so the human does not need to return after every prerequisite completes.
However, pressing Enter in Panel still calls the backend `queue_ready` gate, and that backend rejected any unresolved relation blocker regardless of blocker state. This caused a `ticket conflict` even though the UI correctly showed Queue as available.
Concrete example:
- `00001KVHKWNQS` depends on `00001KVHKWNQA`.
- `00001KVHKWNQA` is already `inprogress`.
- Panel shows `00001KVHKWNQS` as queueable.
- Enter/Queue fails with backend ticket conflict.
## Requirements
- Backend `queue_ready` must match the Panel gating rule.
- A ready Ticket may transition `ready -> queued` when every relation blocker is already `queued` or `inprogress`.
- A ready Ticket must still be blocked when any relation blocker is `planning` or `ready`.
- This change applies to queueing only.
- `queued -> inprogress` acceptance remains blocked while unresolved dependency/blocker relations exist; Orchestrator must preserve execution order after queueing.
- Existing Panel display behavior should remain aligned with backend behavior.
## Implementation summary
- Added backend helper `relation_blocker_allows_queue`.
- Updated `LocalTicketBackend::queue_ready` to filter relation blockers by that helper before rejecting.
- Kept `queued -> inprogress` relation blocker validation unchanged.
- Added backend test coverage for ready Tickets with queued/inprogress blockers.
- Re-ran existing Panel ViewModel test that already asserted ready+queued dependency appears queueable.
## Acceptance criteria
- `ready` Ticket with `depends_on` target in `queued` state can be queued.
- `ready` Ticket with incoming `blocks` blocker in `inprogress` state can be queued.
- `ready` Ticket with dependency/blocker still in `planning` remains rejected.
- Panel and backend queue behavior are consistent.
- Orchestrator acceptance ordering remains guarded by existing `queued -> inprogress` relation check.
## Validation
- `cargo test -p ticket queue_gate --lib`
- `cargo test -p tui workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued --lib`
- `cargo check -p ticket -p tui`
- `cargo fmt`
- `git diff --check`

View File

@ -0,0 +1,7 @@
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:18:00Z -->
## 作成
LocalTicketBackend によって作成されました。
---

View File

@ -1650,11 +1650,15 @@ impl TicketBackend for LocalTicketBackend {
let item = dir.join("item.md"); let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
let blockers = self.relation_blockers_for_meta(&meta)?; let blockers = self.relation_blockers_for_meta(&meta)?;
if !blockers.is_empty() { let active_blockers = blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"ticket {} has unresolved blocking relation(s): {}", "ticket {} has unresolved blocking relation(s): {}",
meta.id, meta.id,
format_relation_blockers(&blockers) format_relation_blockers(&active_blockers)
))); )));
} }
let at = now_utc(); let at = now_utc();
@ -2550,6 +2554,13 @@ fn relation_view_from_records(
view view
} }
fn relation_blocker_allows_queue(blocker: &TicketRelationBlocker) -> bool {
matches!(
blocker.blocking_state,
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
)
}
fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String { fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String {
blockers blockers
.iter() .iter()
@ -4408,6 +4419,65 @@ state: planning
assert_eq!(backend.doctor().unwrap().error_count(), 0); assert_eq!(backend.doctor().unwrap().error_count(), 0);
} }
#[test]
fn queue_gate_allows_ready_ticket_when_blocking_relation_is_already_queued_or_inprogress() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut waiter_input = NewTicket::new("Ready After Queued Dependency");
waiter_input.workflow_state = Some(TicketWorkflowState::Ready);
let waiter = backend.create(waiter_input).unwrap();
let mut dependency_input = NewTicket::new("Queued Dependency");
dependency_input.workflow_state = Some(TicketWorkflowState::Queued);
let dependency = backend.create(dependency_input).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(waiter.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(waiter.id.clone()), "test")
.unwrap();
let queued = backend.show(TicketIdOrSlug::Id(waiter.id.clone())).unwrap();
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
assert_eq!(queued.meta.queued_by.as_deref(), Some("test"));
let mut incoming_input = NewTicket::new("Ready After Inprogress Blocker");
incoming_input.workflow_state = Some(TicketWorkflowState::Ready);
let incoming = backend.create(incoming_input).unwrap();
let mut blocker_input = NewTicket::new("Inprogress Blocker");
blocker_input.workflow_state = Some(TicketWorkflowState::InProgress);
let blocker = backend.create(blocker_input).unwrap();
backend
.add_ticket_relation(
TicketIdOrSlug::Id(blocker.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::Blocks,
target: incoming.id.clone(),
note: None,
author: Some("test".to_string()),
},
)
.unwrap();
backend
.queue_ready(TicketIdOrSlug::Id(incoming.id.clone()), "test")
.unwrap();
let queued_incoming = backend
.show(TicketIdOrSlug::Id(incoming.id.clone()))
.unwrap();
assert_eq!(
queued_incoming.meta.workflow_state,
TicketWorkflowState::Queued
);
}
#[test] #[test]
fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() { fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();