fix: allow queueing behind active blockers
This commit is contained in:
parent
b4cb9fbc41
commit
356d06ef58
0
.yoi/tickets/00001KVHQDS6B/artifacts/.gitkeep
Normal file
0
.yoi/tickets/00001KVHQDS6B/artifacts/.gitkeep
Normal file
21
.yoi/tickets/00001KVHQDS6B/artifacts/relations.json
Normal file
21
.yoi/tickets/00001KVHQDS6B/artifacts/relations.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
.yoi/tickets/00001KVHQDS6B/item.md
Normal file
55
.yoi/tickets/00001KVHQDS6B/item.md
Normal 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`
|
||||||
7
.yoi/tickets/00001KVHQDS6B/thread.md
Normal file
7
.yoi/tickets/00001KVHQDS6B/thread.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!-- event: create author: "yoi ticket" at: 2026-06-20T05:18:00Z -->
|
||||||
|
|
||||||
|
## 作成
|
||||||
|
|
||||||
|
LocalTicketBackend によって作成されました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user