fix: confirm queue closures across clients

This commit is contained in:
2026-08-25 11:51:40 +09:00
parent cabe38db1d
commit a41147916b
9 changed files with 315 additions and 97 deletions
+114 -7
View File
@@ -1147,6 +1147,19 @@ pub fn ticket_queue_guard(
blocked_reason: None,
};
}
if relation_blockers
.iter()
.any(|blocker| blocker.blocking_ticket == summary.id)
{
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Dependency cycle must be resolved before Queue".to_string()),
blocked_reason: Some(format!(
"Ticket {} is part of a dependency cycle",
summary.id
)),
};
}
if relation_blockers
.iter()
.any(|blocker| blocker.blocking_state == TicketWorkflowState::Planning)
@@ -1186,7 +1199,10 @@ fn derive_ticket_workspace_projection(
if !relation_blockers.is_empty() {
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.filter(|blocker| {
blocker.blocking_ticket == summary.id
|| !relation_blocker_allows_ready_queue(blocker)
})
.collect::<Vec<_>>();
if summary.workflow_state != TicketWorkflowState::Ready || !active_blockers.is_empty() {
let blockers_to_report = if active_blockers.is_empty() {
@@ -1225,6 +1241,15 @@ fn derive_ticket_workspace_projection(
.iter()
.collect::<Vec<&TicketRelationBlocker>>(),
);
let mut queue_targets = vec![summary.id.clone()];
queue_targets.extend(
relation_blockers
.iter()
.filter(|blocker| blocker.blocking_state == TicketWorkflowState::Ready)
.map(|blocker| blocker.blocking_ticket.clone()),
);
queue_targets.sort();
queue_targets.dedup();
return TicketWorkspaceProjection {
kind: TicketWorkspaceRowKind::Ticket,
priority: TicketWorkspaceActionPriority::ReadyForQueue,
@@ -1233,7 +1258,8 @@ fn derive_ticket_workspace_projection(
visible_overlay: None,
disabled_reason: None,
key_hint: Some(format!(
"Queue records orchestration demand; dependency relations remain orchestration context ({blockers})."
"Queue targets: {}; active dependencies remain orchestration context ({blockers}).",
queue_targets.join(", ")
)),
blocked_reason: Some(blockers),
queue_guard: TicketQueueGuard {
@@ -4441,14 +4467,66 @@ impl TicketBackend for LocalTicketBackend {
}
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck> {
let ticket = self.show(id)?;
let summary = ticket_summary_from_meta(ticket.meta.clone());
let projection = project_ticket_workspace_item(&summary, &ticket.relations.blockers, None);
let requested_dir = self.find_ticket_dir(&id)?;
let requested_ticket = ticket_id_from_dir(&requested_dir)?;
let mut states = HashMap::new();
for dir in self.iter_ticket_dirs(TicketListQuery::all())? {
let item = dir.join("item.md");
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
states.insert(meta.id, meta.workflow_state);
}
let relations = self.all_ticket_relation_records()?;
let blockers = transitive_dependency_blockers(&requested_ticket, &states, &relations)?;
let ticket = self.ticket_from_dir(&requested_dir)?;
let summary = ticket_summary_from_meta(ticket.meta);
let mut projection = project_ticket_workspace_item(&summary, &blockers, None);
let queue_tickets = if summary.workflow_state == TicketWorkflowState::Ready {
match dependency_queue_plan(&requested_ticket, &states, &relations) {
Ok(queue_tickets) => {
let target_error = queue_tickets.iter().find_map(|candidate| {
self.find_ticket_dir(&TicketIdOrSlug::Id(candidate.clone()))
.and_then(|dir| self.ticket_from_dir(&dir))
.and_then(|ticket| {
match resolve_ready_target(
self.target_authority.as_ref(),
"local",
&ticket,
) {
Ok(_) => Ok(()),
Err(TicketError::TargetAuthorityUnavailable) => Ok(()),
Err(error) => Err(error),
}
})
.err()
});
if let Some(error) = target_error {
projection.queue_guard = TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Queue dependency target validation failed".to_string()),
blocked_reason: Some(error.to_string()),
};
Vec::new()
} else {
queue_tickets
}
}
Err(error) => {
projection.queue_guard = TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some("Queue dependency validation failed".to_string()),
blocked_reason: Some(error.to_string()),
};
Vec::new()
}
}
} else {
Vec::new()
};
Ok(TicketDependencyCheck {
ticket: summary,
blockers: ticket.relations.blockers,
blockers,
queue_guard: projection.queue_guard,
queue_tickets: Vec::new(),
queue_tickets,
recommended_action: projection
.next_action
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
@@ -7693,6 +7771,35 @@ state: planning
.unwrap();
}
let check = backend
.dependency_check(TicketIdOrSlug::Id(first.id.clone()))
.unwrap();
assert!(!check.queue_guard.can_queue_for_orchestrator);
assert!(
check
.queue_guard
.blocked_reason
.as_deref()
.unwrap_or_default()
.contains("cycle")
);
let page = backend
.list_workspace_projection_page(SqliteTicketListPageQuery {
states: vec![TicketWorkflowState::Ready],
limit: 10,
after: None,
})
.unwrap();
let item = page
.items
.iter()
.find(|item| item.summary.id == check.ticket.id)
.unwrap();
assert!(
!project_ticket_workspace_item(&item.summary, &item.relation_blockers, None)
.queue_guard
.can_queue_for_orchestrator
);
let error = backend
.queue_ready(TicketIdOrSlug::Id(first.id.clone()), "orchestrator")
.unwrap_err();