fix: close ticket start authority gaps

This commit is contained in:
2026-08-22 23:17:44 +09:00
parent 285763f4ae
commit 10c80ae514
2 changed files with 54 additions and 5 deletions
-2
View File
@@ -857,8 +857,6 @@ impl SqliteWorkspaceAuthority {
blockers: [ blockers: [
(!has_target).then_some("Ticket target is required".to_string()), (!has_target).then_some("Ticket target is required".to_string()),
has_blockers.then_some("unresolved blocking relations remain".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() .into_iter()
.flatten() .flatten()
+54 -3
View File
@@ -3920,7 +3920,22 @@ async fn scoped_close_ticket(
browser_ticket_detail(&api, &path.id) 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<()> { fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Result<()> {
reject_unguarded_ticket_start(operation)?;
if matches!(operation, TicketBackendOperation::SetWorkflowState { change, .. } if change.to == "done") if matches!(operation, TicketBackendOperation::SetWorkflowState { change, .. } if change.to == "done")
{ {
return Err(Error::TicketAssignmentConflict( return Err(Error::TicketAssignmentConflict(
@@ -13311,6 +13326,27 @@ mod tests {
assert!(reject_non_browser_reopen_auth(&HeaderMap::new()).is_ok()); 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] #[test]
fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() { fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() {
let operation = TicketBackendOperation::SetWorkflowState { let operation = TicketBackendOperation::SetWorkflowState {
@@ -15650,6 +15686,7 @@ mod tests {
false, false,
) )
.unwrap(); .unwrap();
assign_test_orchestrator(&api, &ticket_id);
let path = || ScopedRecordPath { let path = || ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_id.clone(), id: ticket_id.clone(),
@@ -15658,9 +15695,23 @@ mod tests {
let Json(read) = scoped_list_ticket_assignments(State(api.clone()), AxumPath(path())) let Json(read) = scoped_list_ticket_assignments(State(api.clone()), AxumPath(path()))
.await .await
.unwrap(); .unwrap();
assert_eq!(read.assignments.len(), 1); assert_eq!(read.assignments.len(), 2);
assert_eq!(read.assignments[0].assignment_id, assignment.assignment_id); let coder = read
assert_eq!(read.assignments[0].role, TicketAssignmentRole::Coder); .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( let stale = scoped_clear_ticket_assignment(
State(api.clone()), State(api.clone()),