code-graph-context 2.5.3 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,21 @@
1
1
  /**
2
2
  * Swarm Claim Task Tool
3
3
  * Allow an agent to claim an available task from the blackboard
4
+ *
5
+ * Phase 1 improvements:
6
+ * - Atomic claim_and_start action (eliminates race window)
7
+ * - Retry logic on race loss
8
+ * - Recovery actions (abandon, force_start)
4
9
  */
5
10
  import { z } from 'zod';
6
11
  import { Neo4jService } from '../../storage/neo4j/neo4j.service.js';
7
12
  import { TOOL_NAMES, TOOL_METADATA } from '../constants.js';
8
13
  import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, debugLog } from '../utils.js';
9
14
  import { TASK_TYPES, TASK_PRIORITIES } from './swarm-constants.js';
15
+ /** Maximum retries when racing for a task */
16
+ const MAX_CLAIM_RETRIES = 3;
17
+ /** Delay between retries (ms) */
18
+ const RETRY_DELAY_BASE_MS = 50;
10
19
  /**
11
20
  * Query to claim a specific task by ID
12
21
  * Uses atomic update to prevent race conditions
@@ -30,9 +39,10 @@ const CLAIM_TASK_BY_ID_QUERY = `
30
39
  WITH t WHERE t.status IN ['available', 'blocked']
31
40
 
32
41
  // Atomic claim
33
- SET t.status = 'claimed',
42
+ SET t.status = $targetStatus,
34
43
  t.claimedBy = $agentId,
35
44
  t.claimedAt = timestamp(),
45
+ t.startedAt = CASE WHEN $targetStatus = 'in_progress' THEN timestamp() ELSE null END,
36
46
  t.updatedAt = timestamp()
37
47
 
38
48
  // Return task details with target info
@@ -52,6 +62,7 @@ const CLAIM_TASK_BY_ID_QUERY = `
52
62
  t.dependencies as dependencies,
53
63
  t.claimedBy as claimedBy,
54
64
  t.claimedAt as claimedAt,
65
+ t.startedAt as startedAt,
55
66
  t.createdBy as createdBy,
56
67
  t.metadata as metadata,
57
68
  collect(DISTINCT {
@@ -64,6 +75,7 @@ const CLAIM_TASK_BY_ID_QUERY = `
64
75
  /**
65
76
  * Query to claim the highest priority available task matching criteria
66
77
  * Uses APOC locking to prevent race conditions between parallel workers
78
+ * Supports both 'claimed' and 'in_progress' target states for atomic claim_and_start
67
79
  */
68
80
  const CLAIM_NEXT_TASK_QUERY = `
69
81
  // Find available or blocked tasks (blocked tasks may have deps completed now)
@@ -78,7 +90,8 @@ const CLAIM_NEXT_TASK_QUERY = `
78
90
  WITH t, count(dep) as incompleteDeps
79
91
  WHERE incompleteDeps = 0
80
92
 
81
- // Order by priority (highest first), then by creation time (oldest first)
93
+ // Re-establish context for ordering (required by Cypher syntax)
94
+ WITH t
82
95
  ORDER BY t.priorityScore DESC, t.createdAt ASC
83
96
  LIMIT 1
84
97
 
@@ -88,10 +101,11 @@ const CLAIM_NEXT_TASK_QUERY = `
88
101
  // Double-check status after acquiring lock (another worker may have claimed it)
89
102
  WITH t WHERE t.status IN ['available', 'blocked']
90
103
 
91
- // Atomic claim
92
- SET t.status = 'claimed',
104
+ // Atomic claim - supports both claim and claim_and_start via $targetStatus
105
+ SET t.status = $targetStatus,
93
106
  t.claimedBy = $agentId,
94
107
  t.claimedAt = timestamp(),
108
+ t.startedAt = CASE WHEN $targetStatus = 'in_progress' THEN timestamp() ELSE null END,
95
109
  t.updatedAt = timestamp()
96
110
 
97
111
  // Return task details with target info
@@ -111,6 +125,7 @@ const CLAIM_NEXT_TASK_QUERY = `
111
125
  t.dependencies as dependencies,
112
126
  t.claimedBy as claimedBy,
113
127
  t.claimedAt as claimedAt,
128
+ t.startedAt as startedAt,
114
129
  t.createdBy as createdBy,
115
130
  t.metadata as metadata,
116
131
  collect(DISTINCT {
@@ -154,6 +169,71 @@ const RELEASE_TASK_QUERY = `
154
169
  t.title as title,
155
170
  t.status as status
156
171
  `;
172
+ /**
173
+ * Query to abandon a task - releases it with tracking for debugging
174
+ * More explicit than release, tracks abandon history
175
+ */
176
+ const ABANDON_TASK_QUERY = `
177
+ MATCH (t:SwarmTask {id: $taskId, projectId: $projectId})
178
+ WHERE t.claimedBy = $agentId
179
+ AND t.status IN ['claimed', 'in_progress']
180
+
181
+ // Track abandon history
182
+ SET t.status = 'available',
183
+ t.previousClaimedBy = t.claimedBy,
184
+ t.claimedBy = null,
185
+ t.claimedAt = null,
186
+ t.startedAt = null,
187
+ t.updatedAt = timestamp(),
188
+ t.abandonedBy = $agentId,
189
+ t.abandonedAt = timestamp(),
190
+ t.abandonReason = $reason,
191
+ t.abandonCount = COALESCE(t.abandonCount, 0) + 1
192
+
193
+ RETURN t.id as id,
194
+ t.title as title,
195
+ t.status as status,
196
+ t.abandonCount as abandonCount,
197
+ t.abandonReason as abandonReason
198
+ `;
199
+ /**
200
+ * Query to force-start a task that's stuck in claimed state
201
+ * Allows recovery when the normal start action fails
202
+ */
203
+ const FORCE_START_QUERY = `
204
+ MATCH (t:SwarmTask {id: $taskId, projectId: $projectId})
205
+ WHERE t.claimedBy = $agentId
206
+ AND t.status IN ['claimed', 'available']
207
+
208
+ SET t.status = 'in_progress',
209
+ t.claimedBy = $agentId,
210
+ t.claimedAt = COALESCE(t.claimedAt, timestamp()),
211
+ t.startedAt = timestamp(),
212
+ t.updatedAt = timestamp(),
213
+ t.forceStarted = true,
214
+ t.forceStartReason = $reason
215
+
216
+ RETURN t.id as id,
217
+ t.title as title,
218
+ t.status as status,
219
+ t.claimedBy as claimedBy,
220
+ t.startedAt as startedAt,
221
+ t.forceStarted as forceStarted
222
+ `;
223
+ /**
224
+ * Query to get current task state for better error messages
225
+ */
226
+ const GET_TASK_STATE_QUERY = `
227
+ MATCH (t:SwarmTask {id: $taskId, projectId: $projectId})
228
+ RETURN t.id as id,
229
+ t.title as title,
230
+ t.status as status,
231
+ t.claimedBy as claimedBy,
232
+ t.claimedAt as claimedAt,
233
+ t.startedAt as startedAt,
234
+ t.abandonCount as abandonCount,
235
+ t.previousClaimedBy as previousClaimedBy
236
+ `;
157
237
  export const createSwarmClaimTaskTool = (server) => {
158
238
  server.registerTool(TOOL_NAMES.swarmClaimTask, {
159
239
  title: TOOL_METADATA[TOOL_NAMES.swarmClaimTask].title,
@@ -175,16 +255,18 @@ export const createSwarmClaimTaskTool = (server) => {
175
255
  .optional()
176
256
  .describe('Minimum priority level when auto-selecting'),
177
257
  action: z
178
- .enum(['claim', 'start', 'release'])
258
+ .enum(['claim', 'claim_and_start', 'start', 'release', 'abandon', 'force_start'])
179
259
  .optional()
180
- .default('claim')
181
- .describe('Action: claim (reserve task), start (begin work), release (give up task)'),
260
+ .default('claim_and_start')
261
+ .describe('Action: claim_and_start (RECOMMENDED: atomic claim+start), claim (reserve only), ' +
262
+ 'start (begin work on claimed task), release (give up task), ' +
263
+ 'abandon (release with tracking), force_start (recover from stuck claimed state)'),
182
264
  releaseReason: z
183
265
  .string()
184
266
  .optional()
185
- .describe('Reason for releasing the task (required if action=release)'),
267
+ .describe('Reason for releasing/abandoning the task'),
186
268
  },
187
- }, async ({ projectId, swarmId, agentId, taskId, types, minPriority, action = 'claim', releaseReason, }) => {
269
+ }, async ({ projectId, swarmId, agentId, taskId, types, minPriority, action = 'claim_and_start', releaseReason, }) => {
188
270
  const neo4jService = new Neo4jService();
189
271
  // Resolve project ID
190
272
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
@@ -194,15 +276,6 @@ export const createSwarmClaimTaskTool = (server) => {
194
276
  }
195
277
  const resolvedProjectId = projectResult.projectId;
196
278
  try {
197
- await debugLog('Swarm claim task', {
198
- action,
199
- projectId: resolvedProjectId,
200
- swarmId,
201
- agentId,
202
- taskId,
203
- types,
204
- minPriority,
205
- });
206
279
  // Handle release action
207
280
  if (action === 'release') {
208
281
  if (!taskId) {
@@ -215,18 +288,70 @@ export const createSwarmClaimTaskTool = (server) => {
215
288
  reason: releaseReason || 'No reason provided',
216
289
  });
217
290
  if (result.length === 0) {
218
- return createErrorResponse(`Cannot release task ${taskId}. Either it doesn't exist, isn't claimed/in_progress, or you don't own it.`);
291
+ // Get current state for better error message
292
+ const stateResult = await neo4jService.run(GET_TASK_STATE_QUERY, {
293
+ taskId,
294
+ projectId: resolvedProjectId,
295
+ });
296
+ const currentState = stateResult[0];
297
+ return createErrorResponse(`Cannot release task ${taskId}. ` +
298
+ (currentState
299
+ ? `Current state: ${currentState.status}, claimedBy: ${currentState.claimedBy || 'none'}`
300
+ : 'Task not found.'));
301
+ }
302
+ return createSuccessResponse(JSON.stringify({ action: 'released', taskId: result[0].id }));
303
+ }
304
+ // Handle abandon action (release with tracking)
305
+ if (action === 'abandon') {
306
+ if (!taskId) {
307
+ return createErrorResponse('taskId is required for abandon action');
308
+ }
309
+ const result = await neo4jService.run(ABANDON_TASK_QUERY, {
310
+ taskId,
311
+ projectId: resolvedProjectId,
312
+ agentId,
313
+ reason: releaseReason || 'No reason provided',
314
+ });
315
+ if (result.length === 0) {
316
+ const stateResult = await neo4jService.run(GET_TASK_STATE_QUERY, {
317
+ taskId,
318
+ projectId: resolvedProjectId,
319
+ });
320
+ const currentState = stateResult[0];
321
+ return createErrorResponse(`Cannot abandon task ${taskId}. ` +
322
+ (currentState
323
+ ? `Current state: ${currentState.status}, claimedBy: ${currentState.claimedBy || 'none'}`
324
+ : 'Task not found.'));
325
+ }
326
+ const abandonCount = typeof result[0].abandonCount === 'object'
327
+ ? result[0].abandonCount.toNumber()
328
+ : result[0].abandonCount;
329
+ return createSuccessResponse(JSON.stringify({ action: 'abandoned', taskId: result[0].id, abandonCount }));
330
+ }
331
+ // Handle force_start action (recovery from stuck claimed state)
332
+ if (action === 'force_start') {
333
+ if (!taskId) {
334
+ return createErrorResponse('taskId is required for force_start action');
335
+ }
336
+ const result = await neo4jService.run(FORCE_START_QUERY, {
337
+ taskId,
338
+ projectId: resolvedProjectId,
339
+ agentId,
340
+ reason: releaseReason || 'Recovering from stuck state',
341
+ });
342
+ if (result.length === 0) {
343
+ const stateResult = await neo4jService.run(GET_TASK_STATE_QUERY, {
344
+ taskId,
345
+ projectId: resolvedProjectId,
346
+ });
347
+ const currentState = stateResult[0];
348
+ return createErrorResponse(`Cannot force_start task ${taskId}. ` +
349
+ (currentState
350
+ ? `Current state: ${currentState.status}, claimedBy: ${currentState.claimedBy || 'none'}. ` +
351
+ `force_start requires status=claimed|available and you must be the claimant.`
352
+ : 'Task not found.'));
219
353
  }
220
- return createSuccessResponse(JSON.stringify({
221
- success: true,
222
- action: 'released',
223
- task: {
224
- id: result[0].id,
225
- title: result[0].title,
226
- status: result[0].status,
227
- },
228
- message: `Task released and now available for other agents`,
229
- }));
354
+ return createSuccessResponse(JSON.stringify({ action: 'force_started', taskId: result[0].id, status: 'in_progress' }));
230
355
  }
231
356
  // Handle start action
232
357
  if (action === 'start') {
@@ -239,98 +364,100 @@ export const createSwarmClaimTaskTool = (server) => {
239
364
  agentId,
240
365
  });
241
366
  if (result.length === 0) {
242
- return createErrorResponse(`Cannot start task ${taskId}. Either it doesn't exist, isn't claimed, or you don't own it.`);
367
+ // Get current state for better error message
368
+ const stateResult = await neo4jService.run(GET_TASK_STATE_QUERY, {
369
+ taskId,
370
+ projectId: resolvedProjectId,
371
+ });
372
+ const currentState = stateResult[0];
373
+ return createErrorResponse(`Cannot start task ${taskId}. ` +
374
+ (currentState
375
+ ? `Current state: ${currentState.status}, claimedBy: ${currentState.claimedBy || 'none'}. ` +
376
+ `Tip: Use action="force_start" to recover from stuck claimed state, ` +
377
+ `or action="abandon" to release the task.`
378
+ : 'Task not found.'));
243
379
  }
244
- return createSuccessResponse(JSON.stringify({
245
- success: true,
246
- action: 'started',
247
- task: {
248
- id: result[0].id,
249
- status: result[0].status,
250
- claimedBy: result[0].claimedBy,
251
- startedAt: typeof result[0].startedAt === 'object'
252
- ? result[0].startedAt.toNumber()
253
- : result[0].startedAt,
254
- },
255
- message: 'Task is now in progress',
256
- }));
380
+ return createSuccessResponse(JSON.stringify({ action: 'started', taskId: result[0].id, status: 'in_progress' }));
257
381
  }
258
- // Handle claim action
382
+ // Handle claim and claim_and_start actions
383
+ // Determine target status based on action
384
+ const targetStatus = action === 'claim_and_start' ? 'in_progress' : 'claimed';
259
385
  let result;
386
+ let retryCount = 0;
260
387
  if (taskId) {
261
388
  // Claim specific task
262
389
  result = await neo4jService.run(CLAIM_TASK_BY_ID_QUERY, {
263
390
  taskId,
264
391
  projectId: resolvedProjectId,
265
392
  agentId,
393
+ targetStatus,
266
394
  });
267
395
  if (result.length === 0) {
268
- return createErrorResponse(`Cannot claim task ${taskId}. It may not exist, already be claimed, or have incomplete dependencies.`);
396
+ const stateResult = await neo4jService.run(GET_TASK_STATE_QUERY, {
397
+ taskId,
398
+ projectId: resolvedProjectId,
399
+ });
400
+ const currentState = stateResult[0];
401
+ return createErrorResponse(`Cannot claim task ${taskId}. ` +
402
+ (currentState
403
+ ? `Current state: ${currentState.status}, claimedBy: ${currentState.claimedBy || 'none'}`
404
+ : 'Task not found or has incomplete dependencies.'));
269
405
  }
270
406
  }
271
407
  else {
272
- // Auto-select highest priority available task
408
+ // Auto-select highest priority available task with retry logic
273
409
  const minPriorityScore = minPriority
274
410
  ? TASK_PRIORITIES[minPriority]
275
411
  : null;
276
- result = await neo4jService.run(CLAIM_NEXT_TASK_QUERY, {
277
- projectId: resolvedProjectId,
278
- swarmId,
279
- agentId,
280
- types: types || null,
281
- minPriority: minPriorityScore,
282
- });
283
- if (result.length === 0) {
284
- return createSuccessResponse(JSON.stringify({
285
- success: true,
286
- action: 'no_tasks',
287
- message: 'No available tasks matching criteria. All tasks may be claimed, blocked, or completed.',
288
- filters: {
289
- swarmId,
290
- types: types || 'any',
291
- minPriority: minPriority || 'any',
292
- },
293
- }));
294
- }
295
- }
296
- const task = result[0];
297
- // Parse metadata if present
298
- let metadata = null;
299
- if (task.metadata) {
300
- try {
301
- metadata = JSON.parse(task.metadata);
412
+ // Retry loop to handle race conditions
413
+ while (retryCount < MAX_CLAIM_RETRIES) {
414
+ result = await neo4jService.run(CLAIM_NEXT_TASK_QUERY, {
415
+ projectId: resolvedProjectId,
416
+ swarmId,
417
+ agentId,
418
+ types: types || null,
419
+ minPriority: minPriorityScore,
420
+ targetStatus,
421
+ });
422
+ if (result.length > 0) {
423
+ break; // Successfully claimed a task
424
+ }
425
+ retryCount++;
426
+ if (retryCount < MAX_CLAIM_RETRIES) {
427
+ // Wait before retry with exponential backoff
428
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_BASE_MS * Math.pow(2, retryCount - 1)));
429
+ }
302
430
  }
303
- catch {
304
- metadata = task.metadata;
431
+ if (!result || result.length === 0) {
432
+ return createSuccessResponse(JSON.stringify({ action: 'no_tasks', retryAttempts: retryCount }));
305
433
  }
306
434
  }
307
- // Filter out null targets
308
- const targets = (task.targets || []).filter((t) => t.id !== null);
435
+ const task = result[0];
436
+ const actionLabel = action === 'claim_and_start' ? 'claimed_and_started' : 'claimed';
437
+ // Extract valid targets (resolved via :TARGETS relationship)
438
+ const resolvedTargets = (task.targets || [])
439
+ .filter((t) => t?.id)
440
+ .map((t) => ({
441
+ nodeId: t.id,
442
+ name: t.name,
443
+ filePath: t.filePath,
444
+ }));
445
+ // Slim response - only essential fields for agent to do work
309
446
  return createSuccessResponse(JSON.stringify({
310
- success: true,
311
- action: 'claimed',
447
+ action: actionLabel,
312
448
  task: {
313
449
  id: task.id,
314
- projectId: task.projectId,
315
- swarmId: task.swarmId,
316
450
  title: task.title,
317
451
  description: task.description,
318
- type: task.type,
319
- priority: task.priority,
320
- priorityScore: task.priorityScore,
321
452
  status: task.status,
322
- targetNodeIds: task.targetNodeIds,
453
+ type: task.type,
454
+ // Prefer resolved targets over stored nodeIds (resolved targets are from graph relationships)
455
+ targets: resolvedTargets.length > 0 ? resolvedTargets : undefined,
456
+ targetNodeIds: task.targetNodeIds?.length > 0 ? task.targetNodeIds : undefined,
323
457
  targetFilePaths: task.targetFilePaths,
324
- dependencies: task.dependencies,
325
- claimedBy: task.claimedBy,
326
- claimedAt: typeof task.claimedAt === 'object'
327
- ? task.claimedAt.toNumber()
328
- : task.claimedAt,
329
- createdBy: task.createdBy,
330
- metadata,
331
- targets,
458
+ ...(task.dependencies?.length > 0 && { dependencies: task.dependencies }),
332
459
  },
333
- message: 'Task claimed successfully. Use action="start" when you begin working.',
460
+ ...(retryCount > 0 && { retryAttempts: retryCount }),
334
461
  }));
335
462
  }
336
463
  catch (error) {
@@ -9,7 +9,7 @@ import { createErrorResponse, createSuccessResponse, resolveProjectIdOrError, de
9
9
  /**
10
10
  * Neo4j query to delete pheromones by swarm ID
11
11
  */
12
- const CLEANUP_BY_SWARM_QUERY = `
12
+ const CLEANUP_PHEROMONES_BY_SWARM_QUERY = `
13
13
  MATCH (p:Pheromone)
14
14
  WHERE p.projectId = $projectId
15
15
  AND p.swarmId = $swarmId
@@ -18,6 +18,17 @@ const CLEANUP_BY_SWARM_QUERY = `
18
18
  DETACH DELETE p
19
19
  RETURN count(p) as deleted, collect(DISTINCT agentId) as agents, collect(DISTINCT type) as types
20
20
  `;
21
+ /**
22
+ * Neo4j query to delete SwarmTask nodes by swarm ID
23
+ */
24
+ const CLEANUP_TASKS_BY_SWARM_QUERY = `
25
+ MATCH (t:SwarmTask)
26
+ WHERE t.projectId = $projectId
27
+ AND t.swarmId = $swarmId
28
+ WITH t, t.status as status
29
+ DETACH DELETE t
30
+ RETURN count(t) as deleted, collect(DISTINCT status) as statuses
31
+ `;
21
32
  /**
22
33
  * Neo4j query to delete pheromones by agent ID
23
34
  */
@@ -44,11 +55,16 @@ const CLEANUP_ALL_QUERY = `
44
55
  /**
45
56
  * Count queries for dry run
46
57
  */
47
- const COUNT_BY_SWARM_QUERY = `
58
+ const COUNT_PHEROMONES_BY_SWARM_QUERY = `
48
59
  MATCH (p:Pheromone)
49
60
  WHERE p.projectId = $projectId AND p.swarmId = $swarmId AND NOT p.type IN $keepTypes
50
61
  RETURN count(p) as count, collect(DISTINCT p.agentId) as agents, collect(DISTINCT p.type) as types
51
62
  `;
63
+ const COUNT_TASKS_BY_SWARM_QUERY = `
64
+ MATCH (t:SwarmTask)
65
+ WHERE t.projectId = $projectId AND t.swarmId = $swarmId
66
+ RETURN count(t) as count, collect(DISTINCT t.status) as statuses
67
+ `;
52
68
  const COUNT_BY_AGENT_QUERY = `
53
69
  MATCH (p:Pheromone)
54
70
  WHERE p.projectId = $projectId AND p.agentId = $agentId AND NOT p.type IN $keepTypes
@@ -65,9 +81,14 @@ export const createSwarmCleanupTool = (server) => {
65
81
  description: TOOL_METADATA[TOOL_NAMES.swarmCleanup].description,
66
82
  inputSchema: {
67
83
  projectId: z.string().describe('Project ID, name, or path'),
68
- swarmId: z.string().optional().describe('Delete all pheromones from this swarm'),
84
+ swarmId: z.string().optional().describe('Delete all pheromones and tasks from this swarm'),
69
85
  agentId: z.string().optional().describe('Delete all pheromones from this agent'),
70
86
  all: z.boolean().optional().default(false).describe('Delete ALL pheromones in project (use with caution)'),
87
+ includeTasks: z
88
+ .boolean()
89
+ .optional()
90
+ .default(true)
91
+ .describe('Also delete SwarmTask nodes (default: true, only applies when swarmId is provided)'),
71
92
  keepTypes: z
72
93
  .array(z.string())
73
94
  .optional()
@@ -75,7 +96,7 @@ export const createSwarmCleanupTool = (server) => {
75
96
  .describe('Pheromone types to preserve (default: ["warning"])'),
76
97
  dryRun: z.boolean().optional().default(false).describe('Preview what would be deleted without deleting'),
77
98
  },
78
- }, async ({ projectId, swarmId, agentId, all = false, keepTypes = ['warning'], dryRun = false }) => {
99
+ }, async ({ projectId, swarmId, agentId, all = false, includeTasks = true, keepTypes = ['warning'], dryRun = false }) => {
79
100
  const neo4jService = new Neo4jService();
80
101
  // Resolve project ID
81
102
  const projectResult = await resolveProjectIdOrError(projectId, neo4jService);
@@ -89,61 +110,84 @@ export const createSwarmCleanupTool = (server) => {
89
110
  if (!swarmId && !agentId && !all) {
90
111
  return createErrorResponse('Must specify one of: swarmId, agentId, or all=true. Use dryRun=true to preview.');
91
112
  }
92
- await debugLog('Swarm cleanup operation', {
93
- projectId: resolvedProjectId,
94
- swarmId,
95
- agentId,
96
- all,
97
- keepTypes,
98
- dryRun,
99
- });
100
113
  const params = { projectId: resolvedProjectId, keepTypes };
101
- let deleteQuery;
102
- let countQuery;
114
+ let pheromoneDeleteQuery;
115
+ let pheromoneCountQuery;
103
116
  let mode;
104
117
  if (swarmId) {
105
118
  params.swarmId = swarmId;
106
- deleteQuery = CLEANUP_BY_SWARM_QUERY;
107
- countQuery = COUNT_BY_SWARM_QUERY;
119
+ pheromoneDeleteQuery = CLEANUP_PHEROMONES_BY_SWARM_QUERY;
120
+ pheromoneCountQuery = COUNT_PHEROMONES_BY_SWARM_QUERY;
108
121
  mode = 'swarm';
109
122
  }
110
123
  else if (agentId) {
111
124
  params.agentId = agentId;
112
- deleteQuery = CLEANUP_BY_AGENT_QUERY;
113
- countQuery = COUNT_BY_AGENT_QUERY;
125
+ pheromoneDeleteQuery = CLEANUP_BY_AGENT_QUERY;
126
+ pheromoneCountQuery = COUNT_BY_AGENT_QUERY;
114
127
  mode = 'agent';
115
128
  }
116
129
  else {
117
- deleteQuery = CLEANUP_ALL_QUERY;
118
- countQuery = COUNT_ALL_QUERY;
130
+ pheromoneDeleteQuery = CLEANUP_ALL_QUERY;
131
+ pheromoneCountQuery = COUNT_ALL_QUERY;
119
132
  mode = 'all';
120
133
  }
121
134
  if (dryRun) {
122
- const result = await neo4jService.run(countQuery, params);
123
- const count = result[0]?.count ?? 0;
135
+ const pheromoneResult = await neo4jService.run(pheromoneCountQuery, params);
136
+ const pheromoneCount = pheromoneResult[0]?.count ?? 0;
137
+ let taskCount = 0;
138
+ let taskStatuses = [];
139
+ if (swarmId && includeTasks) {
140
+ const taskResult = await neo4jService.run(COUNT_TASKS_BY_SWARM_QUERY, params);
141
+ taskCount = taskResult[0]?.count ?? 0;
142
+ taskCount = typeof taskCount === 'object' && 'toNumber' in taskCount ? taskCount.toNumber() : taskCount;
143
+ taskStatuses = taskResult[0]?.statuses ?? [];
144
+ }
124
145
  return createSuccessResponse(JSON.stringify({
125
146
  success: true,
126
147
  dryRun: true,
127
148
  mode,
128
- wouldDelete: typeof count === 'object' && 'toNumber' in count ? count.toNumber() : count,
129
- agents: result[0]?.agents ?? [],
130
- swarms: result[0]?.swarms ?? [],
131
- types: result[0]?.types ?? [],
149
+ pheromones: {
150
+ wouldDelete: typeof pheromoneCount === 'object' && 'toNumber' in pheromoneCount ? pheromoneCount.toNumber() : pheromoneCount,
151
+ agents: pheromoneResult[0]?.agents ?? [],
152
+ types: pheromoneResult[0]?.types ?? [],
153
+ },
154
+ tasks: swarmId && includeTasks ? {
155
+ wouldDelete: taskCount,
156
+ statuses: taskStatuses,
157
+ } : null,
132
158
  keepTypes,
133
159
  projectId: resolvedProjectId,
134
160
  }));
135
161
  }
136
- const result = await neo4jService.run(deleteQuery, params);
137
- const deleted = result[0]?.deleted ?? 0;
162
+ // Delete pheromones
163
+ const pheromoneResult = await neo4jService.run(pheromoneDeleteQuery, params);
164
+ const pheromonesDeleted = pheromoneResult[0]?.deleted ?? 0;
165
+ // Delete tasks if swarmId provided and includeTasks is true
166
+ let tasksDeleted = 0;
167
+ let taskStatuses = [];
168
+ if (swarmId && includeTasks) {
169
+ const taskResult = await neo4jService.run(CLEANUP_TASKS_BY_SWARM_QUERY, params);
170
+ tasksDeleted = taskResult[0]?.deleted ?? 0;
171
+ tasksDeleted = typeof tasksDeleted === 'object' && 'toNumber' in tasksDeleted ? tasksDeleted.toNumber() : tasksDeleted;
172
+ taskStatuses = taskResult[0]?.statuses ?? [];
173
+ }
138
174
  return createSuccessResponse(JSON.stringify({
139
175
  success: true,
140
176
  mode,
141
- deleted: typeof deleted === 'object' && 'toNumber' in deleted ? deleted.toNumber() : deleted,
142
- agents: result[0]?.agents ?? [],
143
- swarms: result[0]?.swarms ?? [],
144
- types: result[0]?.types ?? [],
177
+ pheromones: {
178
+ deleted: typeof pheromonesDeleted === 'object' && 'toNumber' in pheromonesDeleted ? pheromonesDeleted.toNumber() : pheromonesDeleted,
179
+ agents: pheromoneResult[0]?.agents ?? [],
180
+ types: pheromoneResult[0]?.types ?? [],
181
+ },
182
+ tasks: swarmId && includeTasks ? {
183
+ deleted: tasksDeleted,
184
+ statuses: taskStatuses,
185
+ } : null,
145
186
  keepTypes,
146
187
  projectId: resolvedProjectId,
188
+ message: swarmId && includeTasks
189
+ ? `Cleaned up ${pheromonesDeleted} pheromones and ${tasksDeleted} tasks`
190
+ : `Cleaned up ${pheromonesDeleted} pheromones`,
147
191
  }));
148
192
  }
149
193
  catch (error) {