From 10c80ae514732eaf7199280762421a9ad58ea254 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 22 Aug 2026 23:17:44 +0900 Subject: [PATCH] fix: close ticket start authority gaps --- crates/workspace-server/src/authority.rs | 2 - crates/workspace-server/src/server.rs | 57 ++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 6e49a8d3..f15dd440 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -857,8 +857,6 @@ impl SqliteWorkspaceAuthority { blockers: [ (!has_target).then_some("Ticket target is required".to_string()), has_blockers.then_some("unresolved blocking relations remain".to_string()), - (has_orchestrator && has_coder) - .then_some("Orchestrator and manual Coder assignment conflict".to_string()), ] .into_iter() .flatten() diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a7713d6f..5fbf920b 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3920,7 +3920,22 @@ async fn scoped_close_ticket( browser_ticket_detail(&api, &path.id) } +fn reject_unguarded_ticket_start(operation: &TicketBackendOperation) -> Result<()> { + if matches!( + operation, + TicketBackendOperation::SetWorkflowState { change, .. } + if change.to == "inprogress" && change.from != "inprogress" + ) { + return Err(Error::TicketAssignmentConflict( + "inprogress is guarded by Queue acceptance or atomic ready-state Coder assignment" + .to_string(), + )); + } + Ok(()) +} + fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Result<()> { + reject_unguarded_ticket_start(operation)?; if matches!(operation, TicketBackendOperation::SetWorkflowState { change, .. } if change.to == "done") { return Err(Error::TicketAssignmentConflict( @@ -13311,6 +13326,27 @@ mod tests { assert!(reject_non_browser_reopen_auth(&HeaderMap::new()).is_ok()); } + #[test] + fn generic_worker_state_change_cannot_enter_inprogress() { + for from in ["planning", "ready", "queued"] { + let operation = TicketBackendOperation::SetWorkflowState { + id: TicketIdOrSlug::Query("T1".to_string()), + change: TicketStateChange::new( + from, + "inprogress", + "worker-tool", + "bypass assignment-aware start", + ), + }; + let error = reject_unguarded_ticket_completion(&operation).unwrap_err(); + assert!( + error + .to_string() + .contains("atomic ready-state Coder assignment") + ); + } + } + #[test] fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() { let operation = TicketBackendOperation::SetWorkflowState { @@ -15650,6 +15686,7 @@ mod tests { false, ) .unwrap(); + assign_test_orchestrator(&api, &ticket_id); let path = || ScopedRecordPath { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket_id.clone(), @@ -15658,9 +15695,23 @@ mod tests { let Json(read) = scoped_list_ticket_assignments(State(api.clone()), AxumPath(path())) .await .unwrap(); - assert_eq!(read.assignments.len(), 1); - assert_eq!(read.assignments[0].assignment_id, assignment.assignment_id); - assert_eq!(read.assignments[0].role, TicketAssignmentRole::Coder); + assert_eq!(read.assignments.len(), 2); + let coder = read + .assignments + .iter() + .find(|assignment| assignment.role == TicketAssignmentRole::Coder) + .unwrap(); + assert_eq!(coder.assignment_id, assignment.assignment_id); + let Json(detail) = browser_ticket_detail(&api, &ticket_id).unwrap(); + assert!( + !detail + .action_eligibility + .blockers + .iter() + .any(|blocker| blocker.contains("Orchestrator and manual Coder")) + ); + assert!(!detail.action_eligibility.can_assign_orchestrator); + assert!(!detail.action_eligibility.can_start_manual_coder); let stale = scoped_clear_ticket_assignment( State(api.clone()),