feat: let the current assignee reassign a pending task

Keep the same task id, move the pending inbox, and record TASK_REASSIGNED without advancing the step.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
0264408
2026-09-16 08:40:25 +08:00
co-authored by Cursor
parent 5795778112
commit 850583f326
15 changed files with 286 additions and 26 deletions
@@ -174,6 +174,36 @@ public final class DefaultOrdoEngine implements OrdoEngine {
return completed;
}
@Override
public synchronized ApprovalTask reassign(String taskId, String actor, String newAssignee) {
requireText(newAssignee, "new assignee");
List<ProcessEvent> events = new ArrayList<>();
ApprovalTask reassigned = transactionExecutor.execute(() -> {
ApprovalTask task = requirePendingTaskForActor(taskId, actor);
if (task.assignee().equals(newAssignee)) {
throw new IllegalArgumentException("new assignee must differ from current assignee");
}
boolean duplicatePending = taskRepository.findByInstanceIdAndStepId(task.instanceId(), task.stepId()).stream()
.anyMatch(other -> !other.id().equals(task.id())
&& other.status() == TaskStatus.PENDING
&& other.assignee().equals(newAssignee));
if (duplicatePending) {
throw new IllegalArgumentException("new assignee already has a pending task on this step");
}
if (!taskRepository.reassignIfPending(task.id(), task.assignee(), newAssignee)) {
throw new TaskAlreadyCompletedException(task.id());
}
Instant now = clock.instant();
ApprovalTask updated = new ApprovalTask(task.id(), task.instanceId(), task.stepId(), task.name(),
newAssignee, task.status(), task.createdAt(), task.completedAt(), task.action());
record(events, task.instanceId(), task.id(), task.stepId(), ProcessEventType.TASK_REASSIGNED, actor,
newAssignee, now);
return updated;
});
dispatch(events);
return reassigned;
}
@Override
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
requireText(instanceId, "instance id");
@@ -100,6 +100,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
return delegate.reject(taskId, actor, comment);
}
@Override
public ApprovalTask reassign(String taskId, String actor, String newAssignee) {
return delegate.reassign(taskId, actor, newAssignee);
}
@Override
public ProcessInstance withdraw(String instanceId, String actor, String comment) {
return delegate.withdraw(instanceId, actor, comment);
@@ -76,13 +76,26 @@ public final class InMemoryApprovalTaskRepository implements ApprovalTaskReposit
@Override
public synchronized boolean completeIfPending(ApprovalTask completedTask) {
ApprovalTask current = tasks.get(completedTask.id());
if (current == null || current.status() != TaskStatus.PENDING) {
if (current == null || current.status() != TaskStatus.PENDING
|| !current.assignee().equals(completedTask.assignee())) {
return false;
}
tasks.put(completedTask.id(), completedTask);
return true;
}
@Override
public synchronized boolean reassignIfPending(String taskId, String expectedAssignee, String newAssignee) {
ApprovalTask current = tasks.get(taskId);
if (current == null || current.status() != TaskStatus.PENDING
|| !current.assignee().equals(expectedAssignee)) {
return false;
}
tasks.put(taskId, new ApprovalTask(current.id(), current.instanceId(), current.stepId(), current.name(),
newAssignee, current.status(), current.createdAt(), current.completedAt(), current.action()));
return true;
}
@Override
public synchronized Page<ApprovalTask> query(TaskQuery query, PageRequest pageRequest) {
List<ApprovalTask> matched = tasks.values().stream().filter(task -> matches(task, query)).toList();
@@ -93,6 +93,64 @@ class InMemoryOrdoEngineTest {
assertThrows(TaskAlreadyCompletedException.class, () -> engine.approve(task.id(), "maria"));
}
@Test
void reassignsAPendingTaskToAnotherAssignee() {
var instance = engine.start("leave", "alice");
ApprovalTask task = engine.findPendingTasksByInstanceId(instance.id()).get(0);
ApprovalTask reassigned = engine.reassign(task.id(), "maria", "diana");
assertEquals(task.id(), reassigned.id());
assertEquals("diana", reassigned.assignee());
assertEquals(TaskStatus.PENDING, reassigned.status());
assertTrue(engine.findPendingTasksByAssignee("maria").isEmpty());
assertEquals(List.of(reassigned), engine.findPendingTasksByAssignee("diana"));
ProcessEvent event = engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().stream()
.filter(e -> e.type() == ProcessEventType.TASK_REASSIGNED)
.findFirst()
.orElseThrow();
assertEquals("maria", event.actor());
assertEquals("diana", event.detail());
engine.approve(task.id(), "diana");
assertEquals("hr", engine.findPendingTasksByInstanceId(instance.id()).get(0).stepId());
}
@Test
void reassignIsRejectedForUnauthorizedCompletedSelfAndDuplicateAssignees() {
var instance = engine.start("leave", "alice");
ApprovalTask task = engine.findPendingTasksByInstanceId(instance.id()).get(0);
assertThrows(UnauthorizedTaskOperationException.class, () -> engine.reassign(task.id(), "mallory", "diana"));
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), "maria", "maria"));
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), "maria", " "));
engine.approve(task.id(), "maria");
assertThrows(TaskAlreadyCompletedException.class, () -> engine.reassign(task.id(), "maria", "diana"));
}
@Test
void reassignDoesNotAdvanceAnAllPolicyStepAndRejectsDuplicatePendingAssignee() {
InMemoryOrdoEngine allEngine = new InMemoryOrdoEngine();
allEngine.register(ProcessDefinition.linear("leave-all-reassign", "Leave request", List.of(
new ApprovalStep("manager", "Manager approval", List.of("maria", "mike"), ApprovalPolicy.ALL)
)));
var instance = allEngine.start("leave-all-reassign", "alice");
List<ApprovalTask> managerTasks = allEngine.findTasks(instance.id());
ApprovalTask mariaTask = managerTasks.stream().filter(t -> t.assignee().equals("maria")).findFirst().orElseThrow();
ApprovalTask mikeTask = managerTasks.stream().filter(t -> t.assignee().equals("mike")).findFirst().orElseThrow();
assertThrows(IllegalArgumentException.class, () -> allEngine.reassign(mariaTask.id(), "maria", "mike"));
ApprovalTask reassigned = allEngine.reassign(mariaTask.id(), "maria", "diana");
assertEquals(TaskStatus.PENDING, allEngine.findTask(mikeTask.id()).orElseThrow().status());
assertEquals(ProcessStatus.RUNNING, allEngine.findInstance(instance.id()).orElseThrow().status());
assertEquals(2, allEngine.findPendingTasksByInstanceId(instance.id()).size());
allEngine.approve(reassigned.id(), "diana");
assertEquals(ProcessStatus.RUNNING, allEngine.findInstance(instance.id()).orElseThrow().status());
allEngine.approve(mikeTask.id(), "mike");
assertEquals(ProcessStatus.APPROVED, allEngine.findInstance(instance.id()).orElseThrow().status());
}
@Test
void exposesSpecificExceptionsForMissingAndDuplicateResources() {
assertThrows(DefinitionNotFoundException.class, () -> engine.start("missing", "alice"));
@@ -185,6 +243,7 @@ class InMemoryOrdoEngineTest {
ApprovalTask task = engine.findTasks(instance.id()).get(0);
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana"));
}
@Test
@@ -75,6 +75,24 @@ class InMemoryApprovalTaskRepositoryTest {
assertEquals(List.of(leaveTask), byDefinition.content());
}
@Test
void reassignIfPendingUpdatesOnlyAMatchingPendingTask() {
InMemoryApprovalTaskRepository repository = new InMemoryApprovalTaskRepository();
ApprovalTask pending = task("task-1", "inst-1", "maria", TaskStatus.PENDING, CREATED_AT);
repository.save(pending);
assertTrue(repository.reassignIfPending("task-1", "maria", "diana"));
assertEquals("diana", repository.findById("task-1").orElseThrow().assignee());
assertFalse(repository.reassignIfPending("task-1", "maria", "henry"));
assertEquals("diana", repository.findById("task-1").orElseThrow().assignee());
ApprovalTask completed = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), pending.name(),
"diana", TaskStatus.APPROVED, pending.createdAt(), CREATED_AT.plusSeconds(1), null);
assertTrue(repository.completeIfPending(completed));
assertFalse(repository.reassignIfPending("task-1", "diana", "henry"));
assertFalse(repository.reassignIfPending("missing", "maria", "diana"));
}
private static ApprovalTask task(String id, String instanceId, String assignee, TaskStatus status, Instant createdAt) {
return new ApprovalTask(id, instanceId, "step", "Step", assignee, status, createdAt, null, null);
}