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
@@ -27,7 +27,9 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
+ " VALUES (?, ?, ?, ?, ?, ?, ?)";
private static final String COMPLETE_IF_PENDING =
"UPDATE ordo_approval_task SET status = ?, completed_at = ?, action_actor = ?, action_comment = ?, action_at = ?"
+ " WHERE id = ? AND status = 'PENDING'";
+ " WHERE id = ? AND status = 'PENDING' AND assignee = ?";
private static final String REASSIGN_IF_PENDING =
"UPDATE ordo_approval_task SET assignee = ? WHERE id = ? AND status = 'PENDING' AND assignee = ?";
private static final String SELECT_TASK =
"SELECT " + TASK_COLUMNS + " FROM ordo_approval_task WHERE id = ?";
private static final String SELECT_BY_INSTANCE =
@@ -119,6 +121,24 @@ public final class JdbcApprovalTaskRepository implements ApprovalTaskRepository
}
}
@Override
public boolean reassignIfPending(String taskId, String expectedAssignee, String newAssignee) {
Objects.requireNonNull(taskId, "taskId must not be null");
Objects.requireNonNull(expectedAssignee, "expectedAssignee must not be null");
Objects.requireNonNull(newAssignee, "newAssignee must not be null");
Connection connection = connectionProvider.getConnection();
try (PreparedStatement update = connection.prepareStatement(REASSIGN_IF_PENDING)) {
update.setString(1, newAssignee);
update.setString(2, taskId);
update.setString(3, expectedAssignee);
return update.executeUpdate() == 1;
} catch (SQLException e) {
throw new JdbcStorageException("failed to reassign task: " + taskId, e);
} finally {
connectionProvider.close(connection);
}
}
@Override
public Page<ApprovalTask> query(TaskQuery query, PageRequest pageRequest) {
Objects.requireNonNull(query, "query must not be null");
@@ -40,6 +40,7 @@ public final class ApprovalTaskMapper {
statement.setTimestamp(5, Timestamp.from(action.operatedAt()));
}
statement.setString(6, completedTask.id());
statement.setString(7, completedTask.assignee());
}
public static ApprovalTask read(ResultSet resultSet) throws SQLException {
@@ -154,6 +154,70 @@ class JdbcApprovalTaskRepositoryTest {
assertTrue(Set.of("first", "second").contains(stored.action().comment()));
}
@Test
void reassignIfPendingUpdatesAssigneeAndFailsWhenNotPending() {
ApprovalTask pending = pendingTask("task-1", "inst-1", "manager", "maria", CREATED_AT);
repository.save(pending);
assertTrue(repository.reassignIfPending("task-1", "maria", "diana"));
assertEquals("diana", repository.findById("task-1").orElseThrow().assignee());
assertEquals(List.of(repository.findById("task-1").orElseThrow()), repository.findPendingByAssignee("diana"));
assertTrue(repository.findPendingByAssignee("maria").isEmpty());
assertFalse(repository.reassignIfPending("task-1", "maria", "henry"));
assertTrue(repository.completeIfPending(completedTask(repository.findById("task-1").orElseThrow(), "ok")));
assertFalse(repository.reassignIfPending("task-1", "diana", "henry"));
}
@Test
void onlyOneOfConcurrentReassignAndCompletionWins() throws Exception {
ApprovalTask pending = pendingTask("task-1", "inst-1", "manager", "maria", CREATED_AT);
repository.save(pending);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
AtomicInteger completeWins = new AtomicInteger();
AtomicInteger reassignWins = new AtomicInteger();
Thread completer = new Thread(() -> {
try {
start.await();
if (repository.completeIfPending(completedTask(pending, "ok"))) {
completeWins.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
Thread reassigner = new Thread(() -> {
try {
start.await();
if (repository.reassignIfPending("task-1", "maria", "diana")) {
reassignWins.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
completer.start();
reassigner.start();
start.countDown();
assertTrue(done.await(10, TimeUnit.SECONDS));
assertEquals(1, completeWins.get() + reassignWins.get());
ApprovalTask stored = repository.findById("task-1").orElseThrow();
if (completeWins.get() == 1) {
assertEquals(TaskStatus.APPROVED, stored.status());
assertEquals("maria", stored.assignee());
} else {
assertEquals(TaskStatus.PENDING, stored.status());
assertEquals("diana", stored.assignee());
}
}
private void attempt(CountDownLatch start, CountDownLatch done, AtomicInteger wins, ApprovalTask completed) {
try {
start.await();
@@ -97,6 +97,27 @@ class JdbcOrdoEngineIntegrationTest {
assertEquals("hr", engine.findPendingTasksByInstanceId(instance.id()).get(0).stepId());
}
@Test
void reassignsAPendingTaskAndRecordsHistory() {
ProcessInstance instance = engine.start("leave", "alice");
ApprovalTask task = engine.findPendingTasksByInstanceId(instance.id()).get(0);
ApprovalTask reassigned = engine.reassign(task.id(), "maria", "diana");
assertEquals("diana", reassigned.assignee());
assertEquals(TaskStatus.PENDING, reassigned.status());
assertTrue(engine.findPendingTasksByAssignee("maria").isEmpty());
assertEquals("diana", engine.findPendingTasksByAssignee("diana").get(0).assignee());
List<ProcessEvent> history = engine.queryHistory(instance.id(), new PageRequest(0, 20)).content();
ProcessEvent reassignedEvent = history.stream()
.filter(event -> event.type() == ProcessEventType.TASK_REASSIGNED)
.findFirst()
.orElseThrow();
assertEquals("maria", reassignedEvent.actor());
assertEquals("diana", reassignedEvent.detail());
assertEquals(task.id(), reassignedEvent.taskId());
}
@Test
void rollsBackTheWholeApprovalWhenTheNextStepCannotBeCreated() {
OrdoEngine failingEngine = newEngine((candidate, step, context) -> {