Skip to content

Commit 919f376

Browse files
authored
Merge pull request #7 from jpicklyk/feature/enable-locking-system-update-task-tool
feat: implement atomic locking with mutex-based synchronization
2 parents 1d31e28 + 5a9b225 commit 919f376

File tree

13 files changed

+1011
-666
lines changed

13 files changed

+1011
-666
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ A Kotlin implementation of the Model Context Protocol (MCP) server for comprehen
2323
- **📋 Template-Driven**: 9 built-in templates for consistent documentation
2424
- **🔄 Workflow Automation**: 5 comprehensive workflow prompts
2525
- **🔗 Rich Relationships**: Task dependencies with cycle detection
26+
- **🔒 Concurrent Access Protection**: Built-in sub-agent collision prevention
2627
- **⚡ 37 MCP Tools**: Complete task orchestration API
2728

2829
## Quick Start (2 Minutes)

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ plugins {
77
// Define semantic version components (manually maintained)
88
val majorVersion = "1"
99
val minorVersion = "0"
10-
val patchVersion = "1"
10+
val patchVersion = "2"
1111

1212
// Define release qualifier (empty for stable releases)
1313
// Examples: "", "alpha-01", "beta-02", "rc-01"

docs/api-reference.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Modify existing tasks with validation and relationship preservation
5252
- Complexity refinement
5353
- Tag management
5454
- Relationship preservation
55+
- **Concurrent access protection**: Prevents conflicts when multiple agents work in parallel
5556

5657
### `get_task`
5758
Retrieve individual task details with optional related entity inclusion
@@ -76,6 +77,7 @@ Remove tasks with proper dependency cleanup
7677
- Soft delete support
7778
- Section cleanup
7879
- Relationship validation
80+
- **Concurrent access protection**: Prevents conflicts during deletion operations
7981

8082
### `search_tasks`
8183
Find tasks using flexible filtering by status, priority, tags, and text queries
@@ -135,6 +137,7 @@ Modify existing features while preserving task relationships
135137
- Priority adjustments
136138
- Task relationship preservation
137139
- Tag management
140+
- **Concurrent access protection**: Prevents conflicts when multiple agents work in parallel
138141

139142
### `get_feature`
140143
Retrieve feature details with optional task listings and progressive loading
@@ -159,6 +162,7 @@ Remove features with configurable task handling (cascade or orphan)
159162
- Force deletion override
160163
- Section cleanup
161164
- Relationship validation
165+
- **Concurrent access protection**: Prevents conflicts during deletion operations
162166

163167
### `search_features`
164168
Find features using comprehensive filtering and text search capabilities
@@ -213,6 +217,7 @@ Modify existing projects with relationship preservation and validation
213217
- Priority adjustments
214218
- Relationship preservation
215219
- Tag management
220+
- **Concurrent access protection**: Prevents conflicts when multiple agents work in parallel
216221

217222
### `delete_project`
218223
Remove projects with configurable cascade behavior for contained entities
@@ -225,6 +230,7 @@ Remove projects with configurable cascade behavior for contained entities
225230
- Hard delete vs soft delete
226231
- Comprehensive cleanup
227232
- Relationship validation
233+
- **Concurrent access protection**: Prevents conflicts during deletion operations
228234

229235
### `search_projects`
230236
Find projects using advanced filtering, tagging, and full-text search capabilities
@@ -597,4 +603,32 @@ Use bulk operations when possible:
597603
- Apply workflow prompts for structured process guidance
598604
- Combine multiple tools in logical sequences for complex operations
599605

600-
This comprehensive API provides all the tools needed for sophisticated project management workflows while maintaining context efficiency and supporting AI-driven automation.
606+
## Concurrent Access Protection
607+
608+
The MCP Task Orchestrator includes built-in protection against sub-agent collisions when multiple AI agents work in parallel. The locking system automatically prevents conflicts on update and delete operations for projects, features, and tasks.
609+
610+
### How It Works
611+
612+
- **Automatic Protection**: No additional configuration needed - protection is built into the tools
613+
- **Conflict Detection**: Operations check for conflicts before proceeding
614+
- **Clear Error Messages**: Blocked operations receive descriptive error responses
615+
- **Timeout Protection**: Operations automatically expire after 2 minutes to prevent deadlocks from crashed agents
616+
617+
### Protected Operations
618+
619+
The following tools include concurrent access protection:
620+
- `update_task` and `delete_task`
621+
- `update_feature` and `delete_feature`
622+
- `update_project` and `delete_project`
623+
624+
### Handling Conflicts
625+
626+
When a conflict is detected, the tool returns an error response indicating that another operation is currently active on the same entity. The recommended approach is to wait briefly and retry the operation.
627+
628+
### Best Practices
629+
630+
- **Parallel Workflows**: Multiple agents can safely work on different entities simultaneously
631+
- **Retry Logic**: Implement simple retry logic for conflict scenarios
632+
- **Entity Separation**: Distribute work across different projects, features, or tasks to minimize conflicts
633+
634+
This comprehensive API provides all the tools needed for sophisticated project management workflows while maintaining context efficiency, supporting AI-driven automation, and ensuring safe parallel operation.

src/main/kotlin/io/github/jpicklyk/mcptask/application/service/SimpleLockingService.kt

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.github.jpicklyk.mcptask.application.service
22

33
import io.github.jpicklyk.mcptask.domain.model.*
4+
import kotlinx.coroutines.sync.Mutex
5+
import kotlinx.coroutines.sync.withLock
46
import org.slf4j.LoggerFactory
57
import java.time.Instant
68
import java.util.*
@@ -25,6 +27,28 @@ interface SimpleLockingService {
2527
* Records the completion of an operation.
2628
*/
2729
suspend fun recordOperationComplete(operationId: String)
30+
31+
/**
32+
* Atomically attempts to acquire a lock for the given operation.
33+
* This combines conflict checking and operation recording in a single atomic operation
34+
* to prevent TOCTOU race conditions.
35+
*/
36+
suspend fun tryAcquireLock(operation: LockOperation): LockAcquisitionResult
37+
}
38+
39+
/**
40+
* Result of attempting to acquire a lock atomically.
41+
*/
42+
sealed class LockAcquisitionResult {
43+
/**
44+
* Lock was successfully acquired.
45+
*/
46+
data class Success(val operationId: String) : LockAcquisitionResult()
47+
48+
/**
49+
* Lock acquisition failed due to conflicts.
50+
*/
51+
data class Conflict(val conflictingOperations: List<LockOperation>) : LockAcquisitionResult()
2852
}
2953

3054
/**
@@ -55,20 +79,30 @@ enum class OperationType {
5579
/**
5680
* Simple in-memory implementation of locking service.
5781
*/
58-
class DefaultSimpleLockingService : SimpleLockingService {
82+
class DefaultSimpleLockingService(
83+
/** Operation timeout in minutes - operations older than this are considered expired */
84+
private val operationTimeoutMinutes: Long = 2
85+
) : SimpleLockingService {
5986

6087
private val logger = LoggerFactory.getLogger(DefaultSimpleLockingService::class.java)
6188

6289
// Track active operations in memory
6390
private val activeOperations = ConcurrentHashMap<String, LockOperation>()
6491
private val operationStartTimes = ConcurrentHashMap<String, Instant>()
6592

93+
// Mutex to ensure atomic lock acquisition and prevent TOCTOU race conditions
94+
private val lockMutex = Mutex()
95+
6696
override suspend fun canProceed(operation: LockOperation): Boolean {
6797
logger.debug("Checking if operation '${operation.description}' can proceed")
6898

99+
// Clean up expired operations first (lazy cleanup)
100+
cleanupExpiredOperations()
101+
69102
// Conflict detection logic:
70103
// 1. DELETE operations are blocked by any other operation on the same entity
71-
// 2. Other operations are blocked by DELETE operations on the same entity
104+
// 2. Other operations are blocked by DELETE operations on the same entity
105+
// 3. WRITE operations are blocked by other WRITE operations on the same entity
72106
val hasConflicts = activeOperations.values.any { activeOp ->
73107
val entityOverlap = activeOp.entityIds.intersect(operation.entityIds).isNotEmpty()
74108

@@ -77,7 +111,14 @@ class DefaultSimpleLockingService : SimpleLockingService {
77111
operation.operationType == OperationType.DELETE && entityOverlap -> true
78112
// Other operations are blocked by DELETE operations on same entities
79113
activeOp.operationType == OperationType.DELETE && entityOverlap -> true
80-
// No conflicts for other operation combinations
114+
// WRITE operations are blocked by other WRITE operations on same entities
115+
operation.operationType == OperationType.WRITE && activeOp.operationType == OperationType.WRITE && entityOverlap -> true
116+
// CREATE operations are blocked by other CREATE operations on same entities (prevents duplicate creation)
117+
operation.operationType == OperationType.CREATE && activeOp.operationType == OperationType.CREATE && entityOverlap -> true
118+
// STRUCTURE_CHANGE operations are blocked by any non-READ operation on same entities
119+
operation.operationType == OperationType.STRUCTURE_CHANGE && activeOp.operationType != OperationType.READ && entityOverlap -> true
120+
activeOp.operationType == OperationType.STRUCTURE_CHANGE && operation.operationType != OperationType.READ && entityOverlap -> true
121+
// No conflicts for other operation combinations (READ operations never conflict)
81122
else -> false
82123
}
83124
}
@@ -102,6 +143,49 @@ class DefaultSimpleLockingService : SimpleLockingService {
102143
logger.debug("Completed operation '$operationId'")
103144
}
104145

146+
override suspend fun tryAcquireLock(operation: LockOperation): LockAcquisitionResult {
147+
return lockMutex.withLock {
148+
logger.debug("Attempting to acquire lock for operation '${operation.description}'")
149+
150+
// Clean up expired operations first (lazy cleanup)
151+
cleanupExpiredOperations()
152+
153+
// Check for conflicts using the same logic as canProceed
154+
val conflictingOperations = activeOperations.values.filter { activeOp ->
155+
val entityOverlap = activeOp.entityIds.intersect(operation.entityIds).isNotEmpty()
156+
157+
when {
158+
// DELETE operations are blocked by any operation on same entities
159+
operation.operationType == OperationType.DELETE && entityOverlap -> true
160+
// Other operations are blocked by DELETE operations on same entities
161+
activeOp.operationType == OperationType.DELETE && entityOverlap -> true
162+
// WRITE operations are blocked by other WRITE operations on same entities
163+
operation.operationType == OperationType.WRITE && activeOp.operationType == OperationType.WRITE && entityOverlap -> true
164+
// CREATE operations are blocked by other CREATE operations on same entities (prevents duplicate creation)
165+
operation.operationType == OperationType.CREATE && activeOp.operationType == OperationType.CREATE && entityOverlap -> true
166+
// STRUCTURE_CHANGE operations are blocked by any non-READ operation on same entities
167+
operation.operationType == OperationType.STRUCTURE_CHANGE && activeOp.operationType != OperationType.READ && entityOverlap -> true
168+
activeOp.operationType == OperationType.STRUCTURE_CHANGE && operation.operationType != OperationType.READ && entityOverlap -> true
169+
// No conflicts for other operation combinations (READ operations never conflict)
170+
else -> false
171+
}
172+
}
173+
174+
if (conflictingOperations.isNotEmpty()) {
175+
logger.debug("Operation '${operation.description}' blocked due to ${conflictingOperations.size} conflicting operation(s)")
176+
return@withLock LockAcquisitionResult.Conflict(conflictingOperations)
177+
}
178+
179+
// No conflicts found - acquire the lock atomically
180+
val operationId = "op-${UUID.randomUUID()}"
181+
activeOperations[operationId] = operation
182+
operationStartTimes[operationId] = Instant.now()
183+
184+
logger.debug("Successfully acquired lock for operation '${operation.description}' with ID '$operationId'")
185+
LockAcquisitionResult.Success(operationId)
186+
}
187+
}
188+
105189
/**
106190
* Gets statistics about active operations.
107191
*/
@@ -118,4 +202,31 @@ class DefaultSimpleLockingService : SimpleLockingService {
118202
entry.key to java.time.Duration.between(entry.value, now).toMillis()
119203
}
120204
}
205+
206+
/**
207+
* Cleans up operations that have exceeded the timeout duration.
208+
* This prevents crashed agents from creating permanent deadlocks.
209+
*/
210+
private fun cleanupExpiredOperations(): Int {
211+
val now = Instant.now()
212+
val timeoutThreshold = now.minusSeconds(operationTimeoutMinutes * 60)
213+
214+
val expiredOperations = operationStartTimes.filter { (_, startTime) ->
215+
startTime.isBefore(timeoutThreshold)
216+
}
217+
218+
expiredOperations.keys.forEach { operationId ->
219+
activeOperations.remove(operationId)
220+
operationStartTimes.remove(operationId)
221+
logger.info("Cleaned up expired operation '$operationId' (timeout: ${operationTimeoutMinutes} minutes)")
222+
}
223+
224+
return expiredOperations.size
225+
}
226+
227+
/**
228+
* Manually triggers cleanup of expired operations and returns count of cleaned operations.
229+
* Useful for monitoring and diagnostics.
230+
*/
231+
fun forceCleanupExpiredOperations(): Int = cleanupExpiredOperations()
121232
}

src/main/kotlin/io/github/jpicklyk/mcptask/application/tools/base/SimpleLockAwareToolDefinition.kt

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -164,57 +164,78 @@ abstract class SimpleLockAwareToolDefinition(
164164
}
165165

166166
/**
167-
* Checks if the operation can proceed and handles any lock conflicts gracefully.
167+
* Executes an operation with proper locking lifecycle management.
168+
* This ensures operations are recorded, conflicts are detected, and cleanup happens.
168169
*/
169-
protected suspend fun checkOperationPermissions(
170+
protected suspend fun executeWithLocking(
170171
operationName: String,
171172
entityType: io.github.jpicklyk.mcptask.domain.model.EntityType,
172-
entityId: UUID
173-
): JsonElement? {
174-
// For Phase 2, this just logs the operation intent
175-
// In Phase 3, this will perform actual lock checking
173+
entityId: UUID,
174+
operation: suspend () -> JsonElement
175+
): JsonElement {
176+
// If locking is disabled, execute directly
177+
if (!shouldUseLocking() || lockingService == null || sessionManager == null) {
178+
return operation()
179+
}
176180

177-
lockingService?.let { service ->
178-
sessionManager?.let { session ->
179-
val operation = io.github.jpicklyk.mcptask.application.service.LockOperation(
180-
operationType = mapOperationToLockType(operationName),
181-
toolName = name,
182-
description = "Tool operation: $operationName on ${entityType.name}",
183-
entityIds = setOf(entityId)
184-
)
185-
186-
val canProceed = service.canProceed(operation)
187-
if (!canProceed) {
188-
// Create a simulated conflict error for demonstration
189-
val context = createLockErrorContext(operationName, entityType, entityId, session.getCurrentSession())
190-
val conflictError = LockError.LockConflict(
191-
message = "Operation cannot proceed due to conflicting locks",
192-
conflictingLocks = emptyList(), // Would be populated in real implementation
193-
requestedLock = io.github.jpicklyk.mcptask.domain.model.LockRequest(
181+
val lockOperation = io.github.jpicklyk.mcptask.application.service.LockOperation(
182+
operationType = mapOperationToLockType(operationName),
183+
toolName = name,
184+
description = "Tool operation: $operationName on ${entityType.name}",
185+
entityIds = setOf(entityId)
186+
)
187+
188+
// Atomically attempt to acquire the lock
189+
val lockResult = lockingService.tryAcquireLock(lockOperation)
190+
191+
when (lockResult) {
192+
is io.github.jpicklyk.mcptask.application.service.LockAcquisitionResult.Conflict -> {
193+
val context = createLockErrorContext(operationName, entityType, entityId, sessionManager.getCurrentSession())
194+
val conflictError = LockError.LockConflict(
195+
message = "Operation cannot proceed due to conflicting locks",
196+
conflictingLocks = emptyList(),
197+
requestedLock = io.github.jpicklyk.mcptask.domain.model.LockRequest(
198+
entityId = entityId,
199+
scope = io.github.jpicklyk.mcptask.domain.model.LockScope.TASK,
200+
lockType = io.github.jpicklyk.mcptask.domain.model.LockType.SHARED_WRITE,
201+
sessionId = sessionManager.getCurrentSession(),
202+
operationName = operationName,
203+
expectedDuration = 120L // 2 minutes
204+
),
205+
suggestions = errorHandler.suggestAlternatives(emptyList(),
206+
io.github.jpicklyk.mcptask.domain.model.LockRequest(
194207
entityId = entityId,
195208
scope = io.github.jpicklyk.mcptask.domain.model.LockScope.TASK,
196209
lockType = io.github.jpicklyk.mcptask.domain.model.LockType.SHARED_WRITE,
197-
sessionId = session.getCurrentSession(),
210+
sessionId = sessionManager.getCurrentSession(),
198211
operationName = operationName,
199-
expectedDuration = 1800L // 30 minutes
200-
),
201-
suggestions = errorHandler.suggestAlternatives(emptyList(),
202-
io.github.jpicklyk.mcptask.domain.model.LockRequest(
203-
entityId = entityId,
204-
scope = io.github.jpicklyk.mcptask.domain.model.LockScope.TASK,
205-
lockType = io.github.jpicklyk.mcptask.domain.model.LockType.SHARED_WRITE,
206-
sessionId = session.getCurrentSession(),
207-
operationName = operationName,
208-
expectedDuration = 1800L
209-
), context),
210-
context = context
211-
)
212-
return handleLockError(conflictError)
212+
expectedDuration = 120L
213+
), context),
214+
context = context
215+
)
216+
return handleLockError(conflictError)
217+
}
218+
is io.github.jpicklyk.mcptask.application.service.LockAcquisitionResult.Success -> {
219+
// Lock acquired successfully, continue with operation
220+
val operationId = lockResult.operationId
221+
222+
try {
223+
// Execute the actual operation
224+
return operation()
225+
} finally {
226+
// Always clean up the lock, even if operation fails
227+
lockingService.recordOperationComplete(operationId)
213228
}
214229
}
215230
}
216-
217-
return null // Operation can proceed
231+
}
232+
233+
/**
234+
* Helper method to extract entity ID from different parameter structures.
235+
*/
236+
protected fun extractEntityId(params: JsonElement, paramName: String = "id"): UUID {
237+
return extractUuidFromParameters(params, paramName)
238+
?: throw ToolValidationException("Missing or invalid $paramName parameter")
218239
}
219240

220241
private fun mapOperationToLockType(operationName: String): io.github.jpicklyk.mcptask.application.service.OperationType {

0 commit comments

Comments
 (0)