diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 452ab0ae..5e5a6a2f 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -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::>(); - 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::>(); - 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::>() } 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::>(); - 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::>(); - 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] diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 4781a0bf..d8763657 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -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 \ diff --git a/crates/tui/src/dashboard/render.rs b/crates/tui/src/dashboard/render.rs index f2a5f78e..417620cd 100644 --- a/crates/tui/src/dashboard/render.rs +++ b/crates/tui/src/dashboard/render.rs @@ -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()); } diff --git a/crates/tui/src/dashboard/tests.rs b/crates/tui/src/dashboard/tests.rs index d6381100..24f32f3e 100644 --- a/crates/tui/src/dashboard/tests.rs +++ b/crates/tui/src/dashboard/tests.rs @@ -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] diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 92a84f6f..1400fff4 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -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)); } diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index f15dd440..66b91ddb 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -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) => { diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dc8d2b58..1dfca8a4 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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()), diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index a58f507d..a9259428 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -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, diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index a0afba90..97a3e263 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -466,7 +466,9 @@ {busy === "queue" ? "Queueing…" : "Queue ticket"} {#if !ticket.action_eligibility.can_queue} -

Assign the Orchestrator role and resolve the listed blockers before Queue.

+

Queue requires a valid target, an active Orchestrator assignment, and no active Coder assignment.

+ {:else if ticket.relations.blockers.length > 0} +

Queue records orchestration demand. Dependency relations remain visible so the Orchestrator can decide whether to wait or start work in parallel.

{/if} {/if}