fix: make role assignment retries idempotent

This commit is contained in:
2026-08-22 23:34:52 +09:00
parent 703398bd2c
commit 2cfbb1caea
2 changed files with 62 additions and 5 deletions
+47
View File
@@ -15645,6 +15645,38 @@ mod tests {
}
}
#[tokio::test]
async fn role_assignment_endpoint_replays_same_operation_result() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let ticket = browser_ticket_backend(&api)
.unwrap()
.create(ticket::NewTicket::new("Idempotent assignment"))
.unwrap();
let request = || SetTicketRoleAssignmentRequest {
operation_id: "same-role-operation".to_string(),
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".to_string(),
},
expected_assignment_id: None,
};
let path = || {
AxumPath((
TEST_WORKSPACE_ID.to_string(),
ticket.id.clone(),
"orchestrator".to_string(),
))
};
let Json(first) = scoped_set_ticket_assignment(State(api.clone()), path(), Json(request()))
.await
.unwrap();
let Json(replay) = scoped_set_ticket_assignment(State(api), path(), Json(request()))
.await
.unwrap();
assert_eq!(replay.assignment, first.assignment);
}
#[tokio::test]
async fn ticket_assignment_endpoints_read_and_clear_current_assignment() {
let dir = tempfile::tempdir().unwrap();
@@ -15765,6 +15797,21 @@ mod tests {
.await
.unwrap();
assert_eq!(cleared.assignment, None);
let Json(replayed_clear) = scoped_clear_ticket_assignment(
State(api),
AxumPath((
TEST_WORKSPACE_ID.to_string(),
ticket_id,
"coder".to_string(),
)),
Query(ClearTicketRoleAssignmentQuery {
operation_id: Some("clear-current".to_string()),
assignment_id: Some("assignment-api-1".to_string()),
}),
)
.await
.unwrap();
assert_eq!(replayed_clear.assignment, None);
}
#[tokio::test]
+15 -5
View File
@@ -3618,11 +3618,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
for value in [
record.workspace_id.as_str(),
record.ticket_id.as_str(),
record.assignment_id.as_str(),
record.role.as_str(),
principal_json.as_str(),
record.assigned_by.as_str(),
record.assigned_at.as_str(),
expected_assignment_id.unwrap_or(""),
if allow_reassign { "reassign" } else { "assign" },
] {
@@ -3857,10 +3855,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"ticket-role-assignment:manual-start:v1",
record.workspace_id.as_str(),
record.ticket_id.as_str(),
record.assignment_id.as_str(),
principal_json.as_str(),
record.assigned_by.as_str(),
record.assigned_at.as_str(),
] {
hasher.update(value.as_bytes());
hasher.update([0]);
@@ -4069,7 +4065,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
assignment_id,
principal_json.as_str(),
actor,
occurred_at,
reason.unwrap_or(""),
] {
hasher.update(value.as_bytes());
@@ -4124,6 +4119,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"operation `{operation_id}` was already used for different Ticket assignment input"
)));
}
tx.commit()?;
return Ok(true);
}
let deleted = tx.execute(
"DELETE FROM ticket_current_worker_assignments
@@ -10645,6 +10642,19 @@ INSERT INTO worker_registry (
.start_ready_ticket_with_coder_assignment(&coder, "event-coder", "op-coder")
.unwrap();
assert_eq!(started, coder);
let replay_input = TicketRoleAssignmentRecord {
assignment_id: "coder-regenerated-result".to_string(),
assigned_at: "2026-09-01T00:01:59Z".to_string(),
..coder.clone()
};
let replayed = store
.start_ready_ticket_with_coder_assignment(
&replay_input,
"event-coder-regenerated",
"op-coder",
)
.unwrap();
assert_eq!(replayed, coder);
let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap();
assert_eq!(
ticket.meta.workflow_state,