feat: queue tickets with dependency context

This commit is contained in:
2026-08-24 13:40:33 +09:00
parent d69c367285
commit 379ae214fc
9 changed files with 101 additions and 124 deletions
+49 -90
View File
@@ -1117,7 +1117,7 @@ pub fn project_ticket_workspace_item(
pub fn ticket_queue_guard(
summary: &TicketSummary,
relation_blockers: &[TicketRelationBlocker],
_relation_blockers: &[TicketRelationBlocker],
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
) -> TicketQueueGuard {
if orchestration_overlay.is_some() {
@@ -1140,18 +1140,6 @@ pub fn ticket_queue_guard(
blocked_reason: None,
};
}
let active_blockers = relation_blockers
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
let blockers = format_workspace_relation_blockers(&active_blockers);
return TicketQueueGuard {
can_queue_for_orchestrator: false,
reason: Some(format!("waiting for {blockers}")),
blocked_reason: Some(blockers),
};
}
TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
@@ -1168,7 +1156,7 @@ fn derive_ticket_workspace_projection(
.iter()
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
if summary.workflow_state != TicketWorkflowState::Ready {
let blockers_to_report = if active_blockers.is_empty() {
relation_blockers.iter().collect::<Vec<_>>()
} else {
@@ -1188,9 +1176,9 @@ fn derive_ticket_workspace_projection(
visible_state: summary.workflow_state.as_str().to_string(),
visible_overlay: None,
disabled_reason: Some(format!(
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
"Dependency context: {waiting_reason}. The Orchestrator decides whether work waits or starts in parallel."
)),
key_hint: Some(format!("Gate: {waiting_reason}")),
key_hint: Some(format!("Dependencies: {waiting_reason}")),
blocked_reason: Some(blockers),
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: false,
@@ -1213,9 +1201,9 @@ fn derive_ticket_workspace_projection(
visible_overlay: None,
disabled_reason: None,
key_hint: Some(format!(
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
"Queue records orchestration demand; dependency relations remain scheduling context ({blockers})."
)),
blocked_reason: None,
blocked_reason: Some(blockers),
queue_guard: TicketQueueGuard {
can_queue_for_orchestrator: true,
reason: None,
@@ -3921,16 +3909,6 @@ impl TicketBackend for SqliteTicketBackend {
&self.workspace_id,
&ticket,
)?;
let blockers = ticket
.relations
.blockers
.iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.cloned()
.collect::<Vec<_>>();
if !blockers.is_empty() {
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
}
let at = now_utc();
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, repository_id = ?5, ref_selector = ?6, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", params![self.workspace_id, ticket_id, queued_by, at, target.repository_id, target.ref_selector]).map_err(sqlite_err)?;
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at.clone()), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::from([("queued_by".to_owned(), queued_by.to_owned()), ("queued_at".to_owned(), at), ("repository_id".to_owned(), target.repository_id), ("ref_selector".to_owned(), target.ref_selector)]) })
@@ -4566,18 +4544,6 @@ impl TicketBackend for LocalTicketBackend {
}
let ticket = self.ticket_from_dir(&dir)?;
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
let blockers = self.relation_blockers_for_meta(&meta)?;
let active_blockers = blockers
.into_iter()
.filter(|blocker| !relation_blocker_allows_queue(blocker))
.collect::<Vec<_>>();
if !active_blockers.is_empty() {
return Err(TicketError::BlockingRelations(format!(
"{}: {}",
meta.id,
format_relation_blockers(&active_blockers)
)));
}
let at = now_utc();
let mut change = TicketStateChange::new(
TicketWorkflowState::Ready.as_str(),
@@ -6945,7 +6911,7 @@ mod tests {
}
#[test]
fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() {
fn workspace_projection_queues_ready_ticket_with_unstarted_dependency_context() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let blockers = [blocker_with_state(TicketWorkflowState::Planning)];
let projection = project_ticket_workspace_item(&summary, &blockers, None);
@@ -6953,15 +6919,22 @@ mod tests {
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
assert_eq!(
projection.next_action,
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
);
assert!(!projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.blocked_reason.is_some());
assert!(projection.disabled_reason.is_some());
assert!(projection.disabled_reason.is_none());
assert!(
projection
.key_hint
.as_deref()
.unwrap_or_default()
.contains("scheduling context")
);
}
#[test]
fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() {
fn workspace_projection_queues_ready_ticket_with_queued_dependency_context() {
let summary = summary_with_state(TicketWorkflowState::Ready);
let blockers = [blocker_with_state(TicketWorkflowState::Queued)];
let projection = project_ticket_workspace_item(&summary, &blockers, None);
@@ -6971,13 +6944,13 @@ mod tests {
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
);
assert!(projection.queue_guard.can_queue_for_orchestrator);
assert!(projection.blocked_reason.is_none());
assert!(projection.blocked_reason.is_some());
assert!(
projection
.key_hint
.as_deref()
.unwrap_or_default()
.contains("Orchestrator will preserve order")
.contains("scheduling context")
);
}
@@ -7238,7 +7211,7 @@ state: planning
}
#[test]
fn sqlite_mark_ready_and_queue_enforce_target_and_blockers_atomically() {
fn sqlite_mark_ready_and_queue_preserve_dependency_context_atomically() {
let tmp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test")
.unwrap()
@@ -7284,43 +7257,17 @@ state: planning
.count(),
1
);
assert!(matches!(
backend.queue_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
"orchestrator",
),
Err(TicketError::BlockingRelations(_))
));
let after_rejection = backend
.show(TicketIdOrSlug::Id(implementation.id.clone()))
.unwrap();
assert_eq!(
after_rejection.meta.workflow_state,
TicketWorkflowState::Ready
);
assert!(!after_rejection.events.iter().any(|event| {
event.from.as_deref() == Some("ready") && event.to.as_deref() == Some("queued")
}));
backend
.close(
TicketIdOrSlug::Id(dependency.id),
MarkdownText::new("resolved"),
)
.unwrap();
backend
.queue_ready(
TicketIdOrSlug::Id(implementation.id.clone()),
"orchestrator",
)
.unwrap();
assert_eq!(
backend
.show(TicketIdOrSlug::Id(implementation.id))
.unwrap()
.meta
.workflow_state,
TicketWorkflowState::Queued
);
let queued = backend.show(TicketIdOrSlug::Id(implementation.id)).unwrap();
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
assert_eq!(queued.meta.queued_by.as_deref(), Some("orchestrator"));
assert_eq!(queued.relations.blockers.len(), 1);
assert_eq!(queued.relations.blockers[0].blocking_ticket, dependency.id);
}
#[test]
@@ -8374,7 +8321,7 @@ state: planning
}
#[test]
fn queue_gate_rejects_unresolved_dependency_and_incoming_blocker() {
fn queue_accepts_unresolved_dependency_and_incoming_blocker_as_context() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut blocked_input = NewTicket::new("Blocked Ready");
@@ -8392,12 +8339,15 @@ state: planning
},
)
.unwrap();
let err = backend
backend
.queue_ready(TicketIdOrSlug::Id(blocked.id.clone()), "test")
.unwrap_err()
.to_string();
assert!(err.contains("unresolved blocking relation"), "{err}");
assert!(err.contains(&dependency.id), "{err}");
.unwrap();
let queued = backend
.show(TicketIdOrSlug::Id(blocked.id.clone()))
.unwrap();
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
assert_eq!(queued.relations.blockers.len(), 1);
assert_eq!(queued.relations.blockers[0].blocking_ticket, dependency.id);
let mut incoming_input = NewTicket::new("Incoming Blocked Ready");
incoming_input.workflow_state = Some(TicketWorkflowState::Ready);
@@ -8414,12 +8364,21 @@ state: planning
},
)
.unwrap();
let err = backend
backend
.queue_ready(TicketIdOrSlug::Id(incoming.id.clone()), "test")
.unwrap_err()
.to_string();
assert!(err.contains("unresolved blocking relation"), "{err}");
assert!(err.contains(&blocker.id), "{err}");
.unwrap();
let queued_incoming = backend
.show(TicketIdOrSlug::Id(incoming.id.clone()))
.unwrap();
assert_eq!(
queued_incoming.meta.workflow_state,
TicketWorkflowState::Queued
);
assert_eq!(queued_incoming.relations.blockers.len(), 1);
assert_eq!(
queued_incoming.relations.blockers[0].blocking_ticket,
blocker.id
);
}
#[test]
+1 -1
View File
@@ -143,7 +143,7 @@ The backend applies the same target validation and lock as TicketMarkReady and c
state_changed event, effective target, and planning -> ready transition atomically.";
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
and rejects unresolved blocking relations.";
and preserves unresolved blocking relations as Orchestrator scheduling context rather than Queue admission gates.";
const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \
Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \
as the implementation acceptance step: implementation side effects should happen only after that \
+1 -1
View File
@@ -462,7 +462,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_deref())
{
parts.push(format!("Gate: waiting for {blocked_reason}"));
parts.push(format!("Dependencies: {blocked_reason}"));
} else {
parts.push("Gate: clear".to_string());
}
+7 -8
View File
@@ -1846,24 +1846,23 @@ fn panel_orchestration_overlay_uses_compact_status_column_and_detail_line() {
}
#[test]
fn ready_ticket_with_waiting_gate_shows_queue_disabled_reason() {
fn ready_ticket_with_dependency_context_keeps_queue_action_available() {
let mut row = panel_test_ticket_row(
"00001WAITING",
"Ready but gated",
ActionPriority::Background,
NextUserAction::Wait,
"Ready with dependency context",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
);
row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string());
row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string());
let lines = panel_row_lines(&row, true, 160);
let detail = &lines[1];
let detail_line = plain_line(&detail);
assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on"));
assert!(detail_line.contains("Action: queue disabled"));
assert!(detail_line.contains("Reason: Queue disabled: waiting for BLOCKER-1"));
assert!(detail_line.contains("Dependencies: BLOCKER-1 via depends_on"));
assert!(detail_line.contains("Action: Queue"));
assert!(!detail_line.contains("Queue disabled"));
}
#[test]
+15 -12
View File
@@ -2203,7 +2203,7 @@ mod tests {
}
#[test]
fn workspace_panel_marks_ready_ticket_with_unresolved_relation_waiting_gate() {
fn workspace_panel_queues_ready_ticket_with_unresolved_relation_context() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@@ -2233,14 +2233,9 @@ mod tests {
.unwrap();
assert_eq!(row.kind, PanelRowKind::Ticket);
assert_eq!(row.next_action, Some(NextUserAction::Wait));
assert_eq!(row.priority, ActionPriority::Background);
assert!(
row.disabled_reason
.as_deref()
.unwrap()
.contains("Queue disabled: waiting for")
);
assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert!(row.disabled_reason.is_none());
assert!(
row.ticket
.as_ref()
@@ -2253,7 +2248,7 @@ mod tests {
}
#[test]
fn workspace_panel_allows_ready_ticket_when_relation_prerequisite_is_queued() {
fn workspace_panel_queues_ready_ticket_when_relation_prerequisite_is_queued() {
let temp = TempDir::new().unwrap();
write_ticket_config(temp.path());
let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets"));
@@ -2286,12 +2281,20 @@ mod tests {
assert_eq!(row.next_action, Some(NextUserAction::Queue));
assert_eq!(row.priority, ActionPriority::ReadyForQueue);
assert!(row.disabled_reason.is_none());
assert!(row.ticket.as_ref().unwrap().blocked_reason.is_none());
assert!(
row.ticket
.as_ref()
.unwrap()
.blocked_reason
.as_deref()
.unwrap_or_default()
.contains(&dependency.id)
);
assert!(
row.key_hint
.as_deref()
.unwrap()
.contains("Queue allowed: prerequisites are already queued/in progress")
.contains("dependency relations remain scheduling context")
);
assert!(row.key_hint.as_deref().unwrap().contains(&dependency.id));
}
+5 -9
View File
@@ -847,20 +847,16 @@ impl SqliteWorkspaceAuthority {
can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready
&& has_orchestrator
&& !has_coder
&& has_target
&& !has_blockers,
&& has_target,
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
&& !has_orchestrator
&& !has_coder
&& has_target
&& !has_blockers,
blockers: [
(!has_target).then_some("Ticket target is required".to_string()),
has_blockers.then_some("unresolved blocking relations remain".to_string()),
]
.into_iter()
.flatten()
.collect(),
blockers: [(!has_target).then_some("Ticket target is required".to_string())]
.into_iter()
.flatten()
.collect(),
};
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
Ok(request) => {
+11 -2
View File
@@ -17210,7 +17210,7 @@ mod tests {
.add_ticket_relation(
ticket_id.clone().into(),
ticket::NewTicketRelation {
kind: ticket::TicketRelationKind::Related,
kind: ticket::TicketRelationKind::DependsOn,
target: related_ticket_id.clone(),
note: Some("Browser relation".to_string()),
author: Some("browser-user".to_string()),
@@ -17244,7 +17244,7 @@ mod tests {
assert!(edited.assignment_diagnostics.is_empty());
assert_eq!(edited.relations.outgoing.len(), 1);
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
assert_eq!(edited.relations.outgoing[0].kind, "related");
assert_eq!(edited.relations.outgoing[0].kind, "depends_on");
let Json(commented) = scoped_append_ticket_event(
State(api.clone()),
@@ -17274,6 +17274,13 @@ mod tests {
.unwrap();
assert_eq!(ready.state, "ready");
assign_test_orchestrator(&api, &ticket_id);
let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path()))
.await
.unwrap();
assert!(ready_detail.action_eligibility.can_queue);
assert!(ready_detail.action_eligibility.blockers.is_empty());
assert_eq!(ready_detail.relations.blockers.len(), 1);
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
let Json(queued) = scoped_queue_ticket(
State(api.clone()),
@@ -17284,6 +17291,8 @@ mod tests {
.unwrap();
assert_eq!(queued.state, "queued");
assert_eq!(queued.queued_by.as_deref(), Some("workspace-web"));
assert_eq!(queued.relations.blockers.len(), 1);
assert_eq!(queued.relations.blockers[0].reason_kind, "depends_on");
let Json(closed) = scoped_close_ticket(
State(api),
AxumPath(path()),