feat: queue tickets with dependency context
This commit is contained in:
+49
-90
@@ -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]
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -119,6 +119,15 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
|
||||
);
|
||||
|
||||
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
||||
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
|
||||
assertEquals(
|
||||
source.includes("Queue records orchestration demand. Dependency relations remain visible"),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
source.includes("resolve the listed blockers before Queue"),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
||||
true,
|
||||
|
||||
@@ -466,7 +466,9 @@
|
||||
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
||||
</button>
|
||||
{#if !ticket.action_eligibility.can_queue}
|
||||
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
|
||||
<p class="workspace-empty-copy">Queue requires a valid target, an active Orchestrator assignment, and no active Coder assignment.</p>
|
||||
{:else if ticket.relations.blockers.length > 0}
|
||||
<p class="workspace-empty-copy">Queue records orchestration demand. Dependency relations remain visible so the Orchestrator can decide whether to wait or start work in parallel.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user