diff --git a/.yoi/tickets/00001KVHQDS6B/artifacts/.gitkeep b/.yoi/tickets/00001KVHQDS6B/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KVHQDS6B/artifacts/relations.json b/.yoi/tickets/00001KVHQDS6B/artifacts/relations.json new file mode 100644 index 00000000..8d92a649 --- /dev/null +++ b/.yoi/tickets/00001KVHQDS6B/artifacts/relations.json @@ -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" + } + ] +} diff --git a/.yoi/tickets/00001KVHQDS6B/item.md b/.yoi/tickets/00001KVHQDS6B/item.md new file mode 100644 index 00000000..3b47fb9d --- /dev/null +++ b/.yoi/tickets/00001KVHQDS6B/item.md @@ -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` diff --git a/.yoi/tickets/00001KVHQDS6B/thread.md b/.yoi/tickets/00001KVHQDS6B/thread.md new file mode 100644 index 00000000..f0cb8774 --- /dev/null +++ b/.yoi/tickets/00001KVHQDS6B/thread.md @@ -0,0 +1,7 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index b9cc6d50..93a858cf 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -1650,11 +1650,15 @@ impl TicketBackend for LocalTicketBackend { let item = dir.join("item.md"); let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?; 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::>(); + if !active_blockers.is_empty() { return Err(TicketError::Conflict(format!( "ticket {} has unresolved blocking relation(s): {}", meta.id, - format_relation_blockers(&blockers) + format_relation_blockers(&active_blockers) ))); } let at = now_utc(); @@ -2550,6 +2554,13 @@ fn relation_view_from_records( view } +fn relation_blocker_allows_queue(blocker: &TicketRelationBlocker) -> bool { + matches!( + blocker.blocking_state, + TicketWorkflowState::Queued | TicketWorkflowState::InProgress + ) +} + fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String { blockers .iter() @@ -4408,6 +4419,65 @@ state: planning 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] fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() { let tmp = TempDir::new().unwrap();