@claudetools/tools 0.8.4 → 0.8.5
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.
- package/dist/handlers/tool-handlers.js +133 -3
- package/dist/helpers/circuit-breaker.d.ts +28 -0
- package/dist/helpers/circuit-breaker.js +97 -0
- package/dist/helpers/compact-formatter.d.ts +2 -0
- package/dist/helpers/compact-formatter.js +6 -0
- package/dist/helpers/tasks-retry.d.ts +9 -0
- package/dist/helpers/tasks-retry.js +30 -0
- package/dist/helpers/tasks.d.ts +91 -5
- package/dist/helpers/tasks.js +261 -16
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/tools.js +103 -2
- package/package.json +17 -18
- package/scripts/verify-prompt-compliance.sh +0 -0
|
@@ -11,8 +11,8 @@ import { recordToolCall, getToolCallWarnings } from '../helpers/session-validati
|
|
|
11
11
|
import { queryDependencies, analyzeImpact } from '../helpers/dependencies.js';
|
|
12
12
|
import { checkPatterns } from '../helpers/patterns.js';
|
|
13
13
|
import { formatContextForClaude } from '../helpers/formatter.js';
|
|
14
|
-
import { compactTaskList, compactTaskCreated, compactTaskStart, compactTaskComplete, compactTaskClaim, compactTaskRelease, compactStatusUpdate, compactContextAdded, compactHeartbeat, compactSummary, } from '../helpers/compact-formatter.js';
|
|
15
|
-
import { createTask, listTasks, getTask, claimTask, releaseTask, updateTaskStatus, addTaskContext, getTaskSummary, heartbeatTask, parseJsonArray, getDispatchableTasks, getExecutionContext, resolveTaskDependencies, getEpicStatus, getActiveTaskCount, } from '../helpers/tasks.js';
|
|
14
|
+
import { shortId, compactTaskList, compactTaskCreated, compactTaskStart, compactTaskComplete, compactTaskClaim, compactTaskRelease, compactStatusUpdate, compactContextAdded, compactHeartbeat, compactTaskHandoff, compactSummary, } from '../helpers/compact-formatter.js';
|
|
15
|
+
import { createTask, listTasks, getTask, claimTask, releaseTask, updateTaskStatus, addTaskContext, getTaskSummary, heartbeatTask, handoffTask, parseJsonArray, getDispatchableTasks, getExecutionContext, resolveTaskDependencies, getEpicStatus, getEpicAggregate, getActiveTaskCount, reviseEpic, } from '../helpers/tasks.js';
|
|
16
16
|
import { detectTimedOutTasks, retryTask, failTask, autoRetryTimedOutTasks, } from '../helpers/tasks-retry.js';
|
|
17
17
|
import { detectLibrariesFromPlan } from '../helpers/library-detection.js';
|
|
18
18
|
import { handleGenerateApi, handleGenerateFrontend, handleGenerateComponent, handleListGenerators, handleValidateSpec, handleListPatterns, handleGetPattern, handleDetectPatterns, handleInitProject, } from './codedna-handlers.js';
|
|
@@ -827,6 +827,73 @@ export function registerToolHandlers(server) {
|
|
|
827
827
|
content: [{ type: 'text', text: output }],
|
|
828
828
|
};
|
|
829
829
|
}
|
|
830
|
+
case 'task_plan_revise': {
|
|
831
|
+
const epicId = args?.epic_id;
|
|
832
|
+
const addTasks = args?.add_tasks;
|
|
833
|
+
const removeTaskIds = args?.remove_task_ids;
|
|
834
|
+
const updateTasks = args?.update_tasks;
|
|
835
|
+
try {
|
|
836
|
+
const result = await reviseEpic(DEFAULT_USER_ID, projectId, epicId, {
|
|
837
|
+
add_tasks: addTasks,
|
|
838
|
+
remove_task_ids: removeTaskIds,
|
|
839
|
+
update_tasks: updateTasks,
|
|
840
|
+
});
|
|
841
|
+
mcpLogger.toolResult(name, true, timer(), `Epic ${shortId(epicId)} revised: +${result.added.length} tasks, -${result.removed.length} tasks, ~${result.updated.length} tasks`);
|
|
842
|
+
let output = `# Epic Revised: ${result.epic.title}\n\n`;
|
|
843
|
+
output += `**Epic ID:** \`${result.epic.id}\`\n`;
|
|
844
|
+
output += `**Status:** ${result.epic.status}\n\n`;
|
|
845
|
+
if (result.added.length > 0) {
|
|
846
|
+
output += `## Added Tasks (${result.added.length})\n\n`;
|
|
847
|
+
result.added.forEach((task, i) => {
|
|
848
|
+
output += `${i + 1}. **${task.title}** (\`${shortId(task.id)}\`)`;
|
|
849
|
+
if (task.estimated_effort)
|
|
850
|
+
output += ` - ${task.estimated_effort}`;
|
|
851
|
+
output += `\n`;
|
|
852
|
+
if (task.description)
|
|
853
|
+
output += ` ${task.description}\n`;
|
|
854
|
+
});
|
|
855
|
+
output += '\n';
|
|
856
|
+
}
|
|
857
|
+
if (result.removed.length > 0) {
|
|
858
|
+
output += `## Cancelled Tasks (${result.removed.length})\n\n`;
|
|
859
|
+
result.removed.forEach((taskId, i) => {
|
|
860
|
+
output += `${i + 1}. \`${shortId(taskId)}\` (cancelled)\n`;
|
|
861
|
+
});
|
|
862
|
+
output += '\n';
|
|
863
|
+
}
|
|
864
|
+
if (result.updated.length > 0) {
|
|
865
|
+
output += `## Updated Tasks (${result.updated.length})\n\n`;
|
|
866
|
+
result.updated.forEach((task, i) => {
|
|
867
|
+
output += `${i + 1}. **${task.title}** (\`${shortId(task.id)}\`) - Context added with update details\n`;
|
|
868
|
+
});
|
|
869
|
+
output += '\n';
|
|
870
|
+
}
|
|
871
|
+
// Get updated epic status
|
|
872
|
+
const epicStatus = await getEpicStatus(DEFAULT_USER_ID, projectId, epicId);
|
|
873
|
+
output += `## Current Status\n\n`;
|
|
874
|
+
output += `**Total Tasks:** ${epicStatus.totalTasks}\n`;
|
|
875
|
+
output += `**Progress:** ${epicStatus.percentComplete}% complete\n`;
|
|
876
|
+
output += `**By Status:**\n`;
|
|
877
|
+
Object.entries(epicStatus.byStatus).forEach(([status, count]) => {
|
|
878
|
+
if (count > 0) {
|
|
879
|
+
output += `- ${status}: ${count}\n`;
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
return {
|
|
883
|
+
content: [{ type: 'text', text: output }],
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
mcpLogger.toolResult(name, false, timer(), error instanceof Error ? error.message : 'Unknown error');
|
|
888
|
+
return {
|
|
889
|
+
content: [{
|
|
890
|
+
type: 'text',
|
|
891
|
+
text: `Error revising epic: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
892
|
+
}],
|
|
893
|
+
isError: true,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
830
897
|
case 'task_start': {
|
|
831
898
|
const taskId = args?.task_id;
|
|
832
899
|
const agentId = args?.agent_id || 'claude-code';
|
|
@@ -1048,6 +1115,19 @@ export function registerToolHandlers(server) {
|
|
|
1048
1115
|
content: [{ type: 'text', text: output }],
|
|
1049
1116
|
};
|
|
1050
1117
|
}
|
|
1118
|
+
case 'task_handoff': {
|
|
1119
|
+
const taskId = args?.task_id;
|
|
1120
|
+
const newWorkerType = args?.new_worker_type;
|
|
1121
|
+
const reason = args?.reason;
|
|
1122
|
+
const agentId = args?.agent_id || 'claude-code';
|
|
1123
|
+
const result = await handoffTask(DEFAULT_USER_ID, projectId, taskId, agentId, newWorkerType, reason);
|
|
1124
|
+
mcpLogger.toolResult(name, true, timer());
|
|
1125
|
+
// Compact output
|
|
1126
|
+
const output = compactTaskHandoff(result.data.task, result.data.handed_off, result.data.new_worker_type);
|
|
1127
|
+
return {
|
|
1128
|
+
content: [{ type: 'text', text: output }],
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1051
1131
|
// =========================================================================
|
|
1052
1132
|
// ORCHESTRATION HANDLERS
|
|
1053
1133
|
// =========================================================================
|
|
@@ -1100,7 +1180,12 @@ export function registerToolHandlers(server) {
|
|
|
1100
1180
|
output += `**Task:** ${context.task.title}\n`;
|
|
1101
1181
|
output += `**Task ID:** \`${context.task.id}\`\n`;
|
|
1102
1182
|
output += `**Worker Type:** ${context.worker.name} (\`${context.worker.id}\`)\n`;
|
|
1103
|
-
output += `**Status:** ${context.task.status}\n
|
|
1183
|
+
output += `**Status:** ${context.task.status}\n`;
|
|
1184
|
+
// Display lock expiry warning if present
|
|
1185
|
+
if (context.lockWarning?.warning && context.lockWarning.message) {
|
|
1186
|
+
output += `\n${context.lockWarning.message}\n`;
|
|
1187
|
+
}
|
|
1188
|
+
output += `\n`;
|
|
1104
1189
|
output += `## System Prompt for Worker\n\n`;
|
|
1105
1190
|
output += `\`\`\`\n${context.systemPrompt}\`\`\`\n\n`;
|
|
1106
1191
|
if (context.parentTask) {
|
|
@@ -1224,6 +1309,47 @@ export function registerToolHandlers(server) {
|
|
|
1224
1309
|
content: [{ type: 'text', text: output }],
|
|
1225
1310
|
};
|
|
1226
1311
|
}
|
|
1312
|
+
case 'task_aggregate': {
|
|
1313
|
+
const epicId = args?.epic_id;
|
|
1314
|
+
const includePending = args?.include_pending || false;
|
|
1315
|
+
const aggregate = await getEpicAggregate(DEFAULT_USER_ID, projectId, epicId, includePending);
|
|
1316
|
+
mcpLogger.toolResult(name, true, timer());
|
|
1317
|
+
let output = `# Epic Aggregate: ${aggregate.epic.title}\n\n`;
|
|
1318
|
+
output += `**Epic ID:** \`${epicId}\`\n`;
|
|
1319
|
+
output += `**Epic Status:** ${aggregate.epic.status}\n`;
|
|
1320
|
+
output += `**Description:** ${aggregate.epic.description}\n\n`;
|
|
1321
|
+
output += `## Summary Statistics\n\n`;
|
|
1322
|
+
output += `- **Total Tasks:** ${aggregate.summary_stats.total}\n`;
|
|
1323
|
+
output += `- **Completed:** ${aggregate.summary_stats.completed}\n`;
|
|
1324
|
+
output += `- **In Progress:** ${aggregate.summary_stats.in_progress}\n`;
|
|
1325
|
+
output += `- **Pending:** ${aggregate.summary_stats.pending}\n\n`;
|
|
1326
|
+
output += `## Task Work Logs\n\n`;
|
|
1327
|
+
if (aggregate.tasks.length === 0) {
|
|
1328
|
+
output += `No tasks ${includePending ? '' : 'with work logs '}found for this epic.\n`;
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
for (const task of aggregate.tasks) {
|
|
1332
|
+
const statusEmoji = task.status === 'done' ? '✅' : task.status === 'in_progress' ? '🔄' : '📋';
|
|
1333
|
+
output += `### ${statusEmoji} ${task.title}\n`;
|
|
1334
|
+
output += `- **Task ID:** \`${task.id}\`\n`;
|
|
1335
|
+
output += `- **Status:** ${task.status}\n`;
|
|
1336
|
+
if (task.completed_at) {
|
|
1337
|
+
const completedDate = new Date(task.completed_at).toLocaleString();
|
|
1338
|
+
output += `- **Completed:** ${completedDate}\n`;
|
|
1339
|
+
}
|
|
1340
|
+
if (task.work_log) {
|
|
1341
|
+
output += `- **Work Log:**\n\n`;
|
|
1342
|
+
output += ` ${task.work_log}\n\n`;
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
output += `- **Work Log:** None\n\n`;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return {
|
|
1350
|
+
content: [{ type: 'text', text: output }],
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1227
1353
|
case 'task_detect_timeouts': {
|
|
1228
1354
|
const timedOut = await detectTimedOutTasks(DEFAULT_USER_ID, projectId);
|
|
1229
1355
|
mcpLogger.toolResult(name, true, timer());
|
|
@@ -1278,6 +1404,10 @@ export function registerToolHandlers(server) {
|
|
|
1278
1404
|
if (result.error?.includes('Retry limit exceeded')) {
|
|
1279
1405
|
output += `The task has been marked as **failed** permanently.\n`;
|
|
1280
1406
|
}
|
|
1407
|
+
else if (result.retryAfter) {
|
|
1408
|
+
output += `**Retry After:** ${result.retryAfter}\n`;
|
|
1409
|
+
output += `The task is in exponential backoff. Please wait before retrying.\n`;
|
|
1410
|
+
}
|
|
1281
1411
|
}
|
|
1282
1412
|
return {
|
|
1283
1413
|
content: [{ type: 'text', text: output }],
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface CircuitState {
|
|
2
|
+
failures: number;
|
|
3
|
+
lastFailure: number;
|
|
4
|
+
openedAt: number | null;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Record a worker failure
|
|
8
|
+
* Increments failure count and potentially opens the circuit
|
|
9
|
+
*/
|
|
10
|
+
export declare function recordWorkerFailure(workerType: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Check if circuit is open for a worker type
|
|
13
|
+
* Auto-closes circuit if cooldown period has elapsed
|
|
14
|
+
*/
|
|
15
|
+
export declare function isCircuitOpen(workerType: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Get circuit status for all worker types
|
|
18
|
+
* Useful for monitoring and debugging
|
|
19
|
+
*/
|
|
20
|
+
export declare function getCircuitStatus(): Map<string, CircuitState>;
|
|
21
|
+
/**
|
|
22
|
+
* Manually reset a circuit (for testing or admin override)
|
|
23
|
+
*/
|
|
24
|
+
export declare function resetCircuit(workerType: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Reset all circuits (for testing)
|
|
27
|
+
*/
|
|
28
|
+
export declare function resetAllCircuits(): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Circuit Breaker for Worker Failure Tracking
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Implements the circuit breaker pattern to prevent dispatching tasks to
|
|
6
|
+
// failing workers. Tracks failures per worker type and opens circuit after
|
|
7
|
+
// threshold is reached. Circuit auto-closes after cooldown period.
|
|
8
|
+
//
|
|
9
|
+
// Circuit States:
|
|
10
|
+
// - CLOSED: Normal operation, tasks dispatched
|
|
11
|
+
// - OPEN: Too many failures, tasks NOT dispatched to this worker type
|
|
12
|
+
// - HALF_OPEN: Cooldown complete, attempting recovery (not implemented yet)
|
|
13
|
+
//
|
|
14
|
+
// Configuration
|
|
15
|
+
const FAILURE_THRESHOLD = 3; // Open circuit after N failures
|
|
16
|
+
const FAILURE_WINDOW_MS = 5 * 60 * 1000; // 5 minutes window for counting failures
|
|
17
|
+
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown before auto-close
|
|
18
|
+
// In-memory circuit state (per worker type)
|
|
19
|
+
const circuits = new Map();
|
|
20
|
+
/**
|
|
21
|
+
* Record a worker failure
|
|
22
|
+
* Increments failure count and potentially opens the circuit
|
|
23
|
+
*/
|
|
24
|
+
export function recordWorkerFailure(workerType) {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const state = circuits.get(workerType) || {
|
|
27
|
+
failures: 0,
|
|
28
|
+
lastFailure: 0,
|
|
29
|
+
openedAt: null,
|
|
30
|
+
};
|
|
31
|
+
// If circuit is already open, don't count new failures
|
|
32
|
+
if (state.openedAt !== null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Check if last failure was outside the window
|
|
36
|
+
if (now - state.lastFailure > FAILURE_WINDOW_MS) {
|
|
37
|
+
// Reset failure count (new window)
|
|
38
|
+
state.failures = 1;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Within window, increment
|
|
42
|
+
state.failures++;
|
|
43
|
+
}
|
|
44
|
+
state.lastFailure = now;
|
|
45
|
+
// Check if we should open the circuit
|
|
46
|
+
if (state.failures >= FAILURE_THRESHOLD) {
|
|
47
|
+
state.openedAt = now;
|
|
48
|
+
console.warn(`[Circuit Breaker] OPENED for worker type: ${workerType} (${state.failures} failures)`);
|
|
49
|
+
}
|
|
50
|
+
circuits.set(workerType, state);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if circuit is open for a worker type
|
|
54
|
+
* Auto-closes circuit if cooldown period has elapsed
|
|
55
|
+
*/
|
|
56
|
+
export function isCircuitOpen(workerType) {
|
|
57
|
+
const state = circuits.get(workerType);
|
|
58
|
+
// No failures recorded = closed
|
|
59
|
+
if (!state || state.openedAt === null) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const timeSinceOpened = now - state.openedAt;
|
|
64
|
+
// Check if cooldown has elapsed
|
|
65
|
+
if (timeSinceOpened >= COOLDOWN_MS) {
|
|
66
|
+
// Auto-close circuit
|
|
67
|
+
state.openedAt = null;
|
|
68
|
+
state.failures = 0;
|
|
69
|
+
circuits.set(workerType, state);
|
|
70
|
+
console.info(`[Circuit Breaker] AUTO-CLOSED for worker type: ${workerType} (cooldown elapsed)`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
// Still in cooldown
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get circuit status for all worker types
|
|
78
|
+
* Useful for monitoring and debugging
|
|
79
|
+
*/
|
|
80
|
+
export function getCircuitStatus() {
|
|
81
|
+
// Return a copy of the current state
|
|
82
|
+
return new Map(circuits);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Manually reset a circuit (for testing or admin override)
|
|
86
|
+
*/
|
|
87
|
+
export function resetCircuit(workerType) {
|
|
88
|
+
circuits.delete(workerType);
|
|
89
|
+
console.info(`[Circuit Breaker] RESET for worker type: ${workerType}`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reset all circuits (for testing)
|
|
93
|
+
*/
|
|
94
|
+
export function resetAllCircuits() {
|
|
95
|
+
circuits.clear();
|
|
96
|
+
console.info('[Circuit Breaker] RESET ALL circuits');
|
|
97
|
+
}
|
|
@@ -128,3 +128,9 @@ export function compactMemoryIndex(entries) {
|
|
|
128
128
|
return `${i + 1}. ${critical}${e.summary.slice(0, 60)} (${rel}%) [${shortId(e.id)}]`;
|
|
129
129
|
}).join('\n');
|
|
130
130
|
}
|
|
131
|
+
/** Compact task handoff */
|
|
132
|
+
export function compactTaskHandoff(task, handedOff, newWorkerType) {
|
|
133
|
+
if (!handedOff)
|
|
134
|
+
return `❌ Failed to hand off: ${task.title}`;
|
|
135
|
+
return `🔄 Handed off: ${task.title} → ${newWorkerType} (ready for re-dispatch)`;
|
|
136
|
+
}
|
|
@@ -7,12 +7,19 @@ export interface TimedOutTask {
|
|
|
7
7
|
export interface RetryMetadata {
|
|
8
8
|
retryCount: number;
|
|
9
9
|
lastFailedAt: string;
|
|
10
|
+
lastRetryAt?: string;
|
|
10
11
|
lastError?: string;
|
|
11
12
|
failureHistory: Array<{
|
|
12
13
|
timestamp: string;
|
|
13
14
|
error?: string;
|
|
14
15
|
}>;
|
|
15
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Calculate exponential backoff delay in milliseconds
|
|
19
|
+
* Formula: delay_ms = min(1000 * 2^retry_count, 300000)
|
|
20
|
+
* Max delay is 5 minutes (300000ms)
|
|
21
|
+
*/
|
|
22
|
+
export declare function calculateBackoffDelay(retryCount: number): number;
|
|
16
23
|
/**
|
|
17
24
|
* Detect tasks that have timed out (lock expired while in_progress)
|
|
18
25
|
* These are likely abandoned or failed tasks
|
|
@@ -28,8 +35,10 @@ export declare function retryTask(userId: string, projectId: string, taskId: str
|
|
|
28
35
|
task: Task;
|
|
29
36
|
retryCount: number;
|
|
30
37
|
retriesRemaining: number;
|
|
38
|
+
retryAfter?: string;
|
|
31
39
|
};
|
|
32
40
|
error?: string;
|
|
41
|
+
retryAfter?: string;
|
|
33
42
|
}>;
|
|
34
43
|
/**
|
|
35
44
|
* Mark a task as failed with context
|
|
@@ -11,10 +11,22 @@ function getRetryMetadata(task) {
|
|
|
11
11
|
return {
|
|
12
12
|
retryCount: metadata.retryCount || 0,
|
|
13
13
|
lastFailedAt: metadata.lastFailedAt || '',
|
|
14
|
+
lastRetryAt: metadata.lastRetryAt,
|
|
14
15
|
lastError: metadata.lastError,
|
|
15
16
|
failureHistory: metadata.failureHistory || [],
|
|
16
17
|
};
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Calculate exponential backoff delay in milliseconds
|
|
21
|
+
* Formula: delay_ms = min(1000 * 2^retry_count, 300000)
|
|
22
|
+
* Max delay is 5 minutes (300000ms)
|
|
23
|
+
*/
|
|
24
|
+
export function calculateBackoffDelay(retryCount) {
|
|
25
|
+
const baseDelay = 1000; // 1 second
|
|
26
|
+
const maxDelay = 300000; // 5 minutes
|
|
27
|
+
const delay = baseDelay * Math.pow(2, retryCount);
|
|
28
|
+
return Math.min(delay, maxDelay);
|
|
29
|
+
}
|
|
18
30
|
/**
|
|
19
31
|
* Update retry metadata
|
|
20
32
|
*/
|
|
@@ -27,6 +39,7 @@ function updateRetryMetadata(metadata, error) {
|
|
|
27
39
|
...metadata,
|
|
28
40
|
retryCount,
|
|
29
41
|
lastFailedAt: timestamp,
|
|
42
|
+
lastRetryAt: timestamp,
|
|
30
43
|
lastError: error,
|
|
31
44
|
failureHistory: [
|
|
32
45
|
...failureHistory,
|
|
@@ -90,6 +103,23 @@ export async function retryTask(userId, projectId, taskId, maxRetries = 3, error
|
|
|
90
103
|
error: `Retry limit exceeded (${maxRetries} retries)`,
|
|
91
104
|
};
|
|
92
105
|
}
|
|
106
|
+
// Check exponential backoff window
|
|
107
|
+
if (retryMetadata.lastRetryAt && retryMetadata.retryCount > 0) {
|
|
108
|
+
const lastRetryTime = new Date(retryMetadata.lastRetryAt);
|
|
109
|
+
const now = new Date();
|
|
110
|
+
const timeSinceLastRetry = now.getTime() - lastRetryTime.getTime();
|
|
111
|
+
const backoffDelay = calculateBackoffDelay(retryMetadata.retryCount);
|
|
112
|
+
if (timeSinceLastRetry < backoffDelay) {
|
|
113
|
+
const remainingBackoff = backoffDelay - timeSinceLastRetry;
|
|
114
|
+
const retryAfterTime = new Date(now.getTime() + remainingBackoff);
|
|
115
|
+
const retryAfter = retryAfterTime.toISOString();
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
error: `Task is in backoff window. Please wait ${Math.ceil(remainingBackoff / 1000)} seconds before retrying.`,
|
|
119
|
+
retryAfter,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
93
123
|
// Update metadata with failure info
|
|
94
124
|
const updatedMetadata = updateRetryMetadata(task.metadata || {}, errorContext);
|
|
95
125
|
// Update task with retry metadata and reset status
|
package/dist/helpers/tasks.d.ts
CHANGED
|
@@ -127,15 +127,30 @@ export declare function heartbeatTask(userId: string, projectId: string, taskId:
|
|
|
127
127
|
new_expires_at: string;
|
|
128
128
|
};
|
|
129
129
|
}>;
|
|
130
|
-
export declare const
|
|
130
|
+
export declare const EFFORT_WEIGHTS: {
|
|
131
|
+
readonly xs: 1;
|
|
132
|
+
readonly s: 2;
|
|
133
|
+
readonly m: 4;
|
|
134
|
+
readonly l: 8;
|
|
135
|
+
readonly xl: 16;
|
|
136
|
+
};
|
|
137
|
+
export declare const LOCK_DURATION_BY_EFFORT: {
|
|
138
|
+
readonly xs: 15;
|
|
139
|
+
readonly s: 30;
|
|
140
|
+
readonly m: 60;
|
|
141
|
+
readonly l: 120;
|
|
142
|
+
readonly xl: 240;
|
|
143
|
+
};
|
|
144
|
+
export declare const DEFAULT_CAPACITY_BUDGET = 20;
|
|
131
145
|
/**
|
|
132
|
-
* Get
|
|
133
|
-
* Returns both in_progress and claimed (locked) task counts
|
|
146
|
+
* Get effort-weighted load of currently active tasks
|
|
147
|
+
* Returns both in_progress and claimed (locked) task counts, plus effort-weighted load
|
|
134
148
|
*/
|
|
135
149
|
export declare function getActiveTaskCount(userId: string, projectId: string, epicId?: string): Promise<{
|
|
136
150
|
inProgress: number;
|
|
137
151
|
claimed: number;
|
|
138
152
|
total: number;
|
|
153
|
+
effortLoad: number;
|
|
139
154
|
}>;
|
|
140
155
|
/**
|
|
141
156
|
* Get all tasks ready for parallel dispatch
|
|
@@ -143,9 +158,17 @@ export declare function getActiveTaskCount(userId: string, projectId: string, ep
|
|
|
143
158
|
* - Excludes already claimed tasks
|
|
144
159
|
* - Resolves dependencies (only returns unblocked tasks)
|
|
145
160
|
* - Matches each to appropriate expert worker
|
|
146
|
-
* - Respects
|
|
161
|
+
* - Respects effort-weighted capacity budget
|
|
147
162
|
*/
|
|
148
|
-
export declare function getDispatchableTasks(userId: string, projectId: string, epicId?: string,
|
|
163
|
+
export declare function getDispatchableTasks(userId: string, projectId: string, epicId?: string, capacityBudget?: number): Promise<DispatchableTask[]>;
|
|
164
|
+
/**
|
|
165
|
+
* Check if task lock is about to expire and return warning
|
|
166
|
+
*/
|
|
167
|
+
export declare function getLockExpiryWarning(lockExpiresAt: string | null): {
|
|
168
|
+
warning: boolean;
|
|
169
|
+
minutes_remaining: number;
|
|
170
|
+
message?: string;
|
|
171
|
+
};
|
|
149
172
|
/**
|
|
150
173
|
* Get full execution context for a worker agent
|
|
151
174
|
*/
|
|
@@ -156,11 +179,41 @@ export declare function getExecutionContext(userId: string, projectId: string, t
|
|
|
156
179
|
context: TaskContext[];
|
|
157
180
|
parentTask?: Task;
|
|
158
181
|
siblingTasks?: Task[];
|
|
182
|
+
lockWarning?: {
|
|
183
|
+
warning: boolean;
|
|
184
|
+
minutes_remaining: number;
|
|
185
|
+
message?: string;
|
|
186
|
+
};
|
|
159
187
|
}>;
|
|
160
188
|
/**
|
|
161
189
|
* Find newly unblocked tasks after a completion and update their status to ready
|
|
162
190
|
*/
|
|
163
191
|
export declare function resolveTaskDependencies(userId: string, projectId: string, completedTaskId: string, epicId?: string): Promise<Task[]>;
|
|
192
|
+
/**
|
|
193
|
+
* Aggregate work_log context from all tasks in an epic
|
|
194
|
+
* Used by orchestrator to synthesize final results
|
|
195
|
+
*/
|
|
196
|
+
export declare function getEpicAggregate(userId: string, projectId: string, epicId: string, includePending?: boolean): Promise<{
|
|
197
|
+
epic: {
|
|
198
|
+
id: string;
|
|
199
|
+
title: string;
|
|
200
|
+
description: string;
|
|
201
|
+
status: string;
|
|
202
|
+
};
|
|
203
|
+
tasks: Array<{
|
|
204
|
+
id: string;
|
|
205
|
+
title: string;
|
|
206
|
+
status: string;
|
|
207
|
+
work_log: string | null;
|
|
208
|
+
completed_at: string | null;
|
|
209
|
+
}>;
|
|
210
|
+
summary_stats: {
|
|
211
|
+
total: number;
|
|
212
|
+
completed: number;
|
|
213
|
+
in_progress: number;
|
|
214
|
+
pending: number;
|
|
215
|
+
};
|
|
216
|
+
}>;
|
|
164
217
|
/**
|
|
165
218
|
* Get epic status with progress tracking
|
|
166
219
|
* Auto-completes epic if all child tasks are done
|
|
@@ -173,3 +226,36 @@ export declare function getEpicStatus(userId: string, projectId: string, epicId:
|
|
|
173
226
|
allComplete: boolean;
|
|
174
227
|
autoCompleted: boolean;
|
|
175
228
|
}>;
|
|
229
|
+
export declare function handoffTask(userId: string, projectId: string, taskId: string, agentId: string, newWorkerType: string, reason: string): Promise<{
|
|
230
|
+
success: boolean;
|
|
231
|
+
data: {
|
|
232
|
+
task: Task;
|
|
233
|
+
handed_off: boolean;
|
|
234
|
+
new_worker_type: string;
|
|
235
|
+
};
|
|
236
|
+
}>;
|
|
237
|
+
/**
|
|
238
|
+
* Revise an existing epic by adding/removing/updating tasks
|
|
239
|
+
* Enables iterative planning without recreating the entire epic
|
|
240
|
+
*/
|
|
241
|
+
export declare function reviseEpic(userId: string, projectId: string, epicId: string, changes: {
|
|
242
|
+
add_tasks?: {
|
|
243
|
+
title: string;
|
|
244
|
+
description?: string;
|
|
245
|
+
effort?: string;
|
|
246
|
+
domain?: string;
|
|
247
|
+
blocked_by?: string[];
|
|
248
|
+
}[];
|
|
249
|
+
remove_task_ids?: string[];
|
|
250
|
+
update_tasks?: {
|
|
251
|
+
task_id: string;
|
|
252
|
+
title?: string;
|
|
253
|
+
description?: string;
|
|
254
|
+
effort?: string;
|
|
255
|
+
}[];
|
|
256
|
+
}): Promise<{
|
|
257
|
+
epic: Task;
|
|
258
|
+
added: Task[];
|
|
259
|
+
removed: string[];
|
|
260
|
+
updated: Task[];
|
|
261
|
+
}>;
|
package/dist/helpers/tasks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// =============================================================================
|
|
4
4
|
import { apiRequest } from './api-client.js';
|
|
5
5
|
import { matchTaskToWorker, buildWorkerPrompt } from './workers.js';
|
|
6
|
+
import { isCircuitOpen } from './circuit-breaker.js';
|
|
6
7
|
export function parseJsonArray(value) {
|
|
7
8
|
if (!value)
|
|
8
9
|
return [];
|
|
@@ -51,11 +52,12 @@ export async function getTask(userId, projectId, taskId, full = false) {
|
|
|
51
52
|
const endpoint = full ? 'full' : '';
|
|
52
53
|
return apiRequest(`/api/v1/tasks/${userId}/${projectId}/${taskId}${endpoint ? `/${endpoint}` : ''}`);
|
|
53
54
|
}
|
|
54
|
-
export async function claimTask(userId, projectId, taskId, agentId, lockDurationMinutes
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
lock_duration_minutes
|
|
58
|
-
}
|
|
55
|
+
export async function claimTask(userId, projectId, taskId, agentId, lockDurationMinutes) {
|
|
56
|
+
const body = { agent_id: agentId };
|
|
57
|
+
if (lockDurationMinutes !== undefined) {
|
|
58
|
+
body.lock_duration_minutes = lockDurationMinutes;
|
|
59
|
+
}
|
|
60
|
+
return apiRequest(`/api/v1/tasks/${userId}/${projectId}/${taskId}/claim`, 'POST', body);
|
|
59
61
|
}
|
|
60
62
|
export async function releaseTask(userId, projectId, taskId, agentId, newStatus, workLog) {
|
|
61
63
|
return apiRequest(`/api/v1/tasks/${userId}/${projectId}/${taskId}/release`, 'POST', {
|
|
@@ -90,11 +92,29 @@ export async function heartbeatTask(userId, projectId, taskId, agentId, extendMi
|
|
|
90
92
|
// -----------------------------------------------------------------------------
|
|
91
93
|
// Orchestration Functions
|
|
92
94
|
// -----------------------------------------------------------------------------
|
|
93
|
-
//
|
|
94
|
-
export const
|
|
95
|
+
// Effort weights for capacity planning
|
|
96
|
+
export const EFFORT_WEIGHTS = {
|
|
97
|
+
xs: 1,
|
|
98
|
+
s: 2,
|
|
99
|
+
m: 4,
|
|
100
|
+
l: 8,
|
|
101
|
+
xl: 16,
|
|
102
|
+
};
|
|
103
|
+
// Default lock duration by effort size (in minutes)
|
|
104
|
+
// Used when no explicit lockDurationMinutes is provided to claimTask
|
|
105
|
+
export const LOCK_DURATION_BY_EFFORT = {
|
|
106
|
+
xs: 15, // Extra small: 15 minutes
|
|
107
|
+
s: 30, // Small: 30 minutes (default)
|
|
108
|
+
m: 60, // Medium: 1 hour
|
|
109
|
+
l: 120, // Large: 2 hours
|
|
110
|
+
xl: 240, // Extra large: 4 hours
|
|
111
|
+
};
|
|
112
|
+
// Configuration: Default capacity budget in effort units
|
|
113
|
+
// 20 units allows: 20 xs tasks OR 1 xl + 1 m task OR 5 m tasks, etc.
|
|
114
|
+
export const DEFAULT_CAPACITY_BUDGET = 20;
|
|
95
115
|
/**
|
|
96
|
-
* Get
|
|
97
|
-
* Returns both in_progress and claimed (locked) task counts
|
|
116
|
+
* Get effort-weighted load of currently active tasks
|
|
117
|
+
* Returns both in_progress and claimed (locked) task counts, plus effort-weighted load
|
|
98
118
|
*/
|
|
99
119
|
export async function getActiveTaskCount(userId, projectId, epicId) {
|
|
100
120
|
// Get all in_progress tasks
|
|
@@ -105,10 +125,15 @@ export async function getActiveTaskCount(userId, projectId, epicId) {
|
|
|
105
125
|
});
|
|
106
126
|
let inProgressCount = 0;
|
|
107
127
|
let claimedCount = 0;
|
|
128
|
+
let effortLoad = 0;
|
|
108
129
|
if (inProgressResult.success && inProgressResult.data.length > 0) {
|
|
109
130
|
const now = new Date();
|
|
110
131
|
for (const task of inProgressResult.data) {
|
|
111
132
|
inProgressCount++;
|
|
133
|
+
// Calculate effort weight (default to 'm' = 4 units if not specified)
|
|
134
|
+
const effort = task.estimated_effort || 'm';
|
|
135
|
+
const weight = EFFORT_WEIGHTS[effort] || EFFORT_WEIGHTS.m;
|
|
136
|
+
effortLoad += weight;
|
|
112
137
|
// Check if task is also claimed (has valid lock)
|
|
113
138
|
if (task.assigned_to && task.lock_expires_at) {
|
|
114
139
|
const lockExpires = new Date(task.lock_expires_at);
|
|
@@ -122,6 +147,7 @@ export async function getActiveTaskCount(userId, projectId, epicId) {
|
|
|
122
147
|
inProgress: inProgressCount,
|
|
123
148
|
claimed: claimedCount,
|
|
124
149
|
total: inProgressCount,
|
|
150
|
+
effortLoad,
|
|
125
151
|
};
|
|
126
152
|
}
|
|
127
153
|
/**
|
|
@@ -130,13 +156,13 @@ export async function getActiveTaskCount(userId, projectId, epicId) {
|
|
|
130
156
|
* - Excludes already claimed tasks
|
|
131
157
|
* - Resolves dependencies (only returns unblocked tasks)
|
|
132
158
|
* - Matches each to appropriate expert worker
|
|
133
|
-
* - Respects
|
|
159
|
+
* - Respects effort-weighted capacity budget
|
|
134
160
|
*/
|
|
135
|
-
export async function getDispatchableTasks(userId, projectId, epicId,
|
|
136
|
-
// Get current active task
|
|
161
|
+
export async function getDispatchableTasks(userId, projectId, epicId, capacityBudget = DEFAULT_CAPACITY_BUDGET) {
|
|
162
|
+
// Get current active task effort load
|
|
137
163
|
const activeCount = await getActiveTaskCount(userId, projectId, epicId);
|
|
138
|
-
// Calculate remaining capacity
|
|
139
|
-
|
|
164
|
+
// Calculate remaining effort capacity
|
|
165
|
+
let remainingCapacity = capacityBudget - activeCount.effortLoad;
|
|
140
166
|
if (remainingCapacity <= 0) {
|
|
141
167
|
// No capacity available, return empty array
|
|
142
168
|
return [];
|
|
@@ -178,6 +204,13 @@ export async function getDispatchableTasks(userId, projectId, epicId, maxParalle
|
|
|
178
204
|
continue; // Still blocked
|
|
179
205
|
}
|
|
180
206
|
}
|
|
207
|
+
// Calculate task effort weight
|
|
208
|
+
const effort = task.estimated_effort || 'm';
|
|
209
|
+
const taskEffort = EFFORT_WEIGHTS[effort] || EFFORT_WEIGHTS.m;
|
|
210
|
+
// Check if this task fits within remaining capacity
|
|
211
|
+
if (taskEffort > remainingCapacity) {
|
|
212
|
+
continue; // Task too large for remaining capacity
|
|
213
|
+
}
|
|
181
214
|
// Match to expert worker
|
|
182
215
|
const worker = matchTaskToWorker({
|
|
183
216
|
title: task.title,
|
|
@@ -185,6 +218,11 @@ export async function getDispatchableTasks(userId, projectId, epicId, maxParalle
|
|
|
185
218
|
domain: task.domain || undefined,
|
|
186
219
|
tags: parseJsonArray(task.tags),
|
|
187
220
|
});
|
|
221
|
+
// Circuit breaker check: skip if worker circuit is open
|
|
222
|
+
if (isCircuitOpen(worker.id)) {
|
|
223
|
+
console.warn(`[Circuit Breaker] Skipping task ${task.id} - circuit OPEN for worker type: ${worker.id}`);
|
|
224
|
+
continue; // Skip this task
|
|
225
|
+
}
|
|
188
226
|
// Get task context
|
|
189
227
|
const fullTask = await getTask(userId, projectId, task.id, true);
|
|
190
228
|
const taskData = fullTask.data;
|
|
@@ -195,13 +233,48 @@ export async function getDispatchableTasks(userId, projectId, epicId, maxParalle
|
|
|
195
233
|
parentContext: taskData.parent,
|
|
196
234
|
dependencies: [],
|
|
197
235
|
});
|
|
198
|
-
//
|
|
199
|
-
|
|
236
|
+
// Reduce remaining capacity by the effort of this task
|
|
237
|
+
remainingCapacity -= taskEffort;
|
|
238
|
+
// Stop if no capacity remains
|
|
239
|
+
if (remainingCapacity <= 0) {
|
|
200
240
|
break;
|
|
201
241
|
}
|
|
202
242
|
}
|
|
243
|
+
// Sort by priority: critical → high → medium → low
|
|
244
|
+
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
245
|
+
dispatchable.sort((a, b) => (priorityOrder[a.task.priority] ?? 2) - (priorityOrder[b.task.priority] ?? 2));
|
|
203
246
|
return dispatchable;
|
|
204
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Check if task lock is about to expire and return warning
|
|
250
|
+
*/
|
|
251
|
+
export function getLockExpiryWarning(lockExpiresAt) {
|
|
252
|
+
if (!lockExpiresAt) {
|
|
253
|
+
return { warning: false, minutes_remaining: 0 };
|
|
254
|
+
}
|
|
255
|
+
const now = new Date();
|
|
256
|
+
const expiryTime = new Date(lockExpiresAt);
|
|
257
|
+
const secondsRemaining = (expiryTime.getTime() - now.getTime()) / 1000;
|
|
258
|
+
const minutesRemaining = Math.floor(secondsRemaining / 60);
|
|
259
|
+
// Expired (lock time has passed)
|
|
260
|
+
if (secondsRemaining <= 0) {
|
|
261
|
+
return {
|
|
262
|
+
warning: true,
|
|
263
|
+
minutes_remaining: 0,
|
|
264
|
+
message: '🚨 Lock has EXPIRED. Task may be claimed by another agent.',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// Warn if lock expires within 5 minutes (but not yet expired)
|
|
268
|
+
if (minutesRemaining <= 5 && minutesRemaining >= 1) {
|
|
269
|
+
return {
|
|
270
|
+
warning: true,
|
|
271
|
+
minutes_remaining: minutesRemaining,
|
|
272
|
+
message: `⚠️ Lock expires in ${minutesRemaining} minute${minutesRemaining !== 1 ? 's' : ''}. Call task_heartbeat to extend.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// Less than 1 minute but not expired - no warning (edge case)
|
|
276
|
+
return { warning: false, minutes_remaining: minutesRemaining };
|
|
277
|
+
}
|
|
205
278
|
/**
|
|
206
279
|
* Get full execution context for a worker agent
|
|
207
280
|
*/
|
|
@@ -251,6 +324,8 @@ export async function getExecutionContext(userId, projectId, taskId) {
|
|
|
251
324
|
status: s.status,
|
|
252
325
|
})),
|
|
253
326
|
});
|
|
327
|
+
// Check for lock expiry warning
|
|
328
|
+
const lockWarning = getLockExpiryWarning(task.lock_expires_at);
|
|
254
329
|
return {
|
|
255
330
|
task,
|
|
256
331
|
worker,
|
|
@@ -258,6 +333,7 @@ export async function getExecutionContext(userId, projectId, taskId) {
|
|
|
258
333
|
context: taskData.context,
|
|
259
334
|
parentTask: taskData.parent,
|
|
260
335
|
siblingTasks,
|
|
336
|
+
lockWarning,
|
|
261
337
|
};
|
|
262
338
|
}
|
|
263
339
|
/**
|
|
@@ -303,6 +379,81 @@ export async function resolveTaskDependencies(userId, projectId, completedTaskId
|
|
|
303
379
|
}
|
|
304
380
|
return newlyUnblocked;
|
|
305
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Aggregate work_log context from all tasks in an epic
|
|
384
|
+
* Used by orchestrator to synthesize final results
|
|
385
|
+
*/
|
|
386
|
+
export async function getEpicAggregate(userId, projectId, epicId, includePending = false) {
|
|
387
|
+
// Get the epic
|
|
388
|
+
const epicResult = await getTask(userId, projectId, epicId);
|
|
389
|
+
if (!epicResult.success) {
|
|
390
|
+
throw new Error(`Epic not found: ${epicId}`);
|
|
391
|
+
}
|
|
392
|
+
const epic = epicResult.data;
|
|
393
|
+
// Verify it's an epic
|
|
394
|
+
if (epic.type !== 'epic') {
|
|
395
|
+
throw new Error(`Task ${epicId} is not an epic (type: ${epic.type})`);
|
|
396
|
+
}
|
|
397
|
+
// Get all child tasks
|
|
398
|
+
const tasksResult = await listTasks(userId, projectId, {
|
|
399
|
+
parent_id: epicId,
|
|
400
|
+
limit: 100,
|
|
401
|
+
});
|
|
402
|
+
if (!tasksResult.success) {
|
|
403
|
+
return {
|
|
404
|
+
epic: {
|
|
405
|
+
id: epic.id,
|
|
406
|
+
title: epic.title,
|
|
407
|
+
description: epic.description,
|
|
408
|
+
status: epic.status,
|
|
409
|
+
},
|
|
410
|
+
tasks: [],
|
|
411
|
+
summary_stats: {
|
|
412
|
+
total: 0,
|
|
413
|
+
completed: 0,
|
|
414
|
+
in_progress: 0,
|
|
415
|
+
pending: 0,
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const allTasks = tasksResult.data;
|
|
420
|
+
// Get work_log context for each task
|
|
421
|
+
const tasksWithLogs = await Promise.all(allTasks.map(async (task) => {
|
|
422
|
+
// Get full task data to access context
|
|
423
|
+
const fullTaskResult = await getTask(userId, projectId, task.id, true);
|
|
424
|
+
const fullTaskData = fullTaskResult.data;
|
|
425
|
+
// Find work_log context
|
|
426
|
+
const workLogContext = fullTaskData.context?.find(ctx => ctx.context_type === 'work_log');
|
|
427
|
+
return {
|
|
428
|
+
id: task.id,
|
|
429
|
+
title: task.title,
|
|
430
|
+
status: task.status,
|
|
431
|
+
work_log: workLogContext?.content || null,
|
|
432
|
+
completed_at: task.completed_at,
|
|
433
|
+
};
|
|
434
|
+
}));
|
|
435
|
+
// Filter based on includePending
|
|
436
|
+
const filteredTasks = includePending
|
|
437
|
+
? tasksWithLogs
|
|
438
|
+
: tasksWithLogs.filter(t => t.status === 'done' || t.status === 'in_progress');
|
|
439
|
+
// Calculate stats
|
|
440
|
+
const stats = {
|
|
441
|
+
total: allTasks.length,
|
|
442
|
+
completed: allTasks.filter(t => t.status === 'done').length,
|
|
443
|
+
in_progress: allTasks.filter(t => t.status === 'in_progress').length,
|
|
444
|
+
pending: allTasks.filter(t => t.status === 'ready' || t.status === 'backlog').length,
|
|
445
|
+
};
|
|
446
|
+
return {
|
|
447
|
+
epic: {
|
|
448
|
+
id: epic.id,
|
|
449
|
+
title: epic.title,
|
|
450
|
+
description: epic.description,
|
|
451
|
+
status: epic.status,
|
|
452
|
+
},
|
|
453
|
+
tasks: filteredTasks,
|
|
454
|
+
summary_stats: stats,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
306
457
|
/**
|
|
307
458
|
* Get epic status with progress tracking
|
|
308
459
|
* Auto-completes epic if all child tasks are done
|
|
@@ -368,3 +519,97 @@ export async function getEpicStatus(userId, projectId, epicId) {
|
|
|
368
519
|
autoCompleted,
|
|
369
520
|
};
|
|
370
521
|
}
|
|
522
|
+
export async function handoffTask(userId, projectId, taskId, agentId, newWorkerType, reason) {
|
|
523
|
+
return apiRequest(`/api/v1/tasks/${userId}/${projectId}/${taskId}/handoff`, 'POST', {
|
|
524
|
+
agent_id: agentId,
|
|
525
|
+
new_worker_type: newWorkerType,
|
|
526
|
+
reason,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Revise an existing epic by adding/removing/updating tasks
|
|
531
|
+
* Enables iterative planning without recreating the entire epic
|
|
532
|
+
*/
|
|
533
|
+
export async function reviseEpic(userId, projectId, epicId, changes) {
|
|
534
|
+
// Verify epic exists and is not done
|
|
535
|
+
const epicResult = await getTask(userId, projectId, epicId);
|
|
536
|
+
if (!epicResult.success) {
|
|
537
|
+
throw new Error(`Epic not found: ${epicId}`);
|
|
538
|
+
}
|
|
539
|
+
const epic = epicResult.data;
|
|
540
|
+
if (epic.type !== 'epic') {
|
|
541
|
+
throw new Error(`Task ${epicId} is not an epic (type: ${epic.type})`);
|
|
542
|
+
}
|
|
543
|
+
if (epic.status === 'done') {
|
|
544
|
+
throw new Error(`Cannot revise completed epic: ${epicId}`);
|
|
545
|
+
}
|
|
546
|
+
const added = [];
|
|
547
|
+
const removed = [];
|
|
548
|
+
const updated = [];
|
|
549
|
+
// Add new tasks
|
|
550
|
+
if (changes.add_tasks && changes.add_tasks.length > 0) {
|
|
551
|
+
for (const taskDef of changes.add_tasks) {
|
|
552
|
+
const taskResult = await createTask(userId, projectId, {
|
|
553
|
+
type: 'task',
|
|
554
|
+
title: taskDef.title,
|
|
555
|
+
description: taskDef.description,
|
|
556
|
+
parent_id: epicId,
|
|
557
|
+
priority: epic.priority,
|
|
558
|
+
estimated_effort: taskDef.effort,
|
|
559
|
+
blocked_by: taskDef.blocked_by,
|
|
560
|
+
domain: taskDef.domain,
|
|
561
|
+
});
|
|
562
|
+
added.push(taskResult.data);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Remove tasks (set to cancelled status)
|
|
566
|
+
if (changes.remove_task_ids && changes.remove_task_ids.length > 0) {
|
|
567
|
+
for (const taskId of changes.remove_task_ids) {
|
|
568
|
+
try {
|
|
569
|
+
await updateTaskStatus(userId, projectId, taskId, 'cancelled', 'system:epic-revision');
|
|
570
|
+
removed.push(taskId);
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
// Log but continue - task may not exist
|
|
574
|
+
console.warn(`Failed to cancel task ${taskId}:`, error);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// Update existing tasks
|
|
579
|
+
// Note: The API doesn't support PATCH for tasks, so we can only update status
|
|
580
|
+
// For title/description/effort updates, we'll need to add context explaining the change
|
|
581
|
+
if (changes.update_tasks && changes.update_tasks.length > 0) {
|
|
582
|
+
for (const update of changes.update_tasks) {
|
|
583
|
+
try {
|
|
584
|
+
const taskResult = await getTask(userId, projectId, update.task_id);
|
|
585
|
+
if (!taskResult.success)
|
|
586
|
+
continue;
|
|
587
|
+
const task = taskResult.data;
|
|
588
|
+
// Add context about the updates since we can't modify fields directly
|
|
589
|
+
const updates = [];
|
|
590
|
+
if (update.title && update.title !== task.title) {
|
|
591
|
+
updates.push(`Title updated: "${task.title}" → "${update.title}"`);
|
|
592
|
+
}
|
|
593
|
+
if (update.description && update.description !== task.description) {
|
|
594
|
+
updates.push(`Description updated: "${update.description}"`);
|
|
595
|
+
}
|
|
596
|
+
if (update.effort && update.effort !== task.estimated_effort) {
|
|
597
|
+
updates.push(`Effort updated: ${task.estimated_effort || 'unset'} → ${update.effort}`);
|
|
598
|
+
}
|
|
599
|
+
if (updates.length > 0) {
|
|
600
|
+
await addTaskContext(userId, projectId, update.task_id, 'decision', updates.join('\n'), 'task_plan_revise', 'epic-revision');
|
|
601
|
+
updated.push(task);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
console.warn(`Failed to update task ${update.task_id}:`, error);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
epic,
|
|
611
|
+
added,
|
|
612
|
+
removed,
|
|
613
|
+
updated,
|
|
614
|
+
};
|
|
615
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export type { ExpertWorker } from './helpers/workers.js';
|
|
2
2
|
export type { Task, TaskContext, DispatchableTask } from './helpers/tasks.js';
|
|
3
3
|
export type { DeduplicationTracker } from './context/index.js';
|
|
4
|
+
export type { CircuitState } from './helpers/circuit-breaker.js';
|
|
4
5
|
export { EXPERT_WORKERS, matchTaskToWorker } from './helpers/workers.js';
|
|
5
6
|
export { parseJsonArray, getDispatchableTasks, getExecutionContext, resolveTaskDependencies, createTask, listTasks, getTask, claimTask, releaseTask, updateTaskStatus, addTaskContext, getTaskSummary, heartbeatTask } from './helpers/tasks.js';
|
|
6
7
|
export { injectContext } from './helpers/api-client.js';
|
|
7
8
|
export { recordToolCall, getToolCallWarnings, getSessionStats, clearSessionState, recordContextReference, calculateEngagementScore, getEngagementStats } from './helpers/session-validation.js';
|
|
8
9
|
export { createDeduplicationTracker, InMemoryDeduplicationTracker } from './context/index.js';
|
|
10
|
+
export { recordWorkerFailure, isCircuitOpen, getCircuitStatus, resetCircuit, resetAllCircuits } from './helpers/circuit-breaker.js';
|
|
9
11
|
export declare function startServer(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { parseJsonArray, getDispatchableTasks, getExecutionContext, resolveTaskD
|
|
|
17
17
|
export { injectContext } from './helpers/api-client.js';
|
|
18
18
|
export { recordToolCall, getToolCallWarnings, getSessionStats, clearSessionState, recordContextReference, calculateEngagementScore, getEngagementStats } from './helpers/session-validation.js';
|
|
19
19
|
export { createDeduplicationTracker, InMemoryDeduplicationTracker } from './context/index.js';
|
|
20
|
+
export { recordWorkerFailure, isCircuitOpen, getCircuitStatus, resetCircuit, resetAllCircuits } from './helpers/circuit-breaker.js';
|
|
20
21
|
// =============================================================================
|
|
21
22
|
// Server Initialization
|
|
22
23
|
// =============================================================================
|
package/dist/tools.js
CHANGED
|
@@ -465,6 +465,54 @@ High hit rate memories (frequently fetched after being indexed) are good candida
|
|
|
465
465
|
required: ['goal', 'epic_title', 'tasks'],
|
|
466
466
|
},
|
|
467
467
|
},
|
|
468
|
+
{
|
|
469
|
+
name: 'task_plan_revise',
|
|
470
|
+
description: 'Modify an existing epic by adding new tasks, removing tasks, or updating task details. Enables iterative planning without recreating the entire plan.',
|
|
471
|
+
inputSchema: {
|
|
472
|
+
type: 'object',
|
|
473
|
+
properties: {
|
|
474
|
+
epic_id: {
|
|
475
|
+
type: 'string',
|
|
476
|
+
description: 'The ID of the epic to revise',
|
|
477
|
+
},
|
|
478
|
+
add_tasks: {
|
|
479
|
+
type: 'array',
|
|
480
|
+
items: {
|
|
481
|
+
type: 'object',
|
|
482
|
+
properties: {
|
|
483
|
+
title: { type: 'string' },
|
|
484
|
+
description: { type: 'string' },
|
|
485
|
+
effort: { type: 'string', enum: ['xs', 's', 'm', 'l', 'xl'] },
|
|
486
|
+
domain: { type: 'string' },
|
|
487
|
+
blocked_by: { type: 'array', items: { type: 'string' } },
|
|
488
|
+
},
|
|
489
|
+
required: ['title'],
|
|
490
|
+
},
|
|
491
|
+
description: 'New tasks to add to the epic',
|
|
492
|
+
},
|
|
493
|
+
remove_task_ids: {
|
|
494
|
+
type: 'array',
|
|
495
|
+
items: { type: 'string' },
|
|
496
|
+
description: 'Task IDs to cancel (sets status to cancelled)',
|
|
497
|
+
},
|
|
498
|
+
update_tasks: {
|
|
499
|
+
type: 'array',
|
|
500
|
+
items: {
|
|
501
|
+
type: 'object',
|
|
502
|
+
properties: {
|
|
503
|
+
task_id: { type: 'string' },
|
|
504
|
+
title: { type: 'string' },
|
|
505
|
+
description: { type: 'string' },
|
|
506
|
+
effort: { type: 'string', enum: ['xs', 's', 'm', 'l', 'xl'] },
|
|
507
|
+
},
|
|
508
|
+
required: ['task_id'],
|
|
509
|
+
},
|
|
510
|
+
description: 'Tasks to update (adds context about changes)',
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
required: ['epic_id'],
|
|
514
|
+
},
|
|
515
|
+
},
|
|
468
516
|
{
|
|
469
517
|
name: 'task_start',
|
|
470
518
|
description: 'PROACTIVE: When starting work on a task, claim it and get all context. Use this before beginning any task work.',
|
|
@@ -608,7 +656,16 @@ High hit rate memories (frequently fetched after being indexed) are good candida
|
|
|
608
656
|
},
|
|
609
657
|
{
|
|
610
658
|
name: 'task_claim',
|
|
611
|
-
description:
|
|
659
|
+
description: `Claim a task for working on it. Creates a lock preventing other agents from working on it.
|
|
660
|
+
|
|
661
|
+
Lock duration is automatically scaled based on task effort:
|
|
662
|
+
- xs: 15 minutes
|
|
663
|
+
- s: 30 minutes (default if no effort specified)
|
|
664
|
+
- m: 60 minutes (1 hour)
|
|
665
|
+
- l: 120 minutes (2 hours)
|
|
666
|
+
- xl: 240 minutes (4 hours)
|
|
667
|
+
|
|
668
|
+
Explicit lock_duration_minutes parameter overrides the effort-based default.`,
|
|
612
669
|
inputSchema: {
|
|
613
670
|
type: 'object',
|
|
614
671
|
properties: {
|
|
@@ -622,7 +679,7 @@ High hit rate memories (frequently fetched after being indexed) are good candida
|
|
|
622
679
|
},
|
|
623
680
|
lock_duration_minutes: {
|
|
624
681
|
type: 'number',
|
|
625
|
-
description: '
|
|
682
|
+
description: 'Optional: Override effort-based lock duration (in minutes)',
|
|
626
683
|
},
|
|
627
684
|
},
|
|
628
685
|
required: ['task_id', 'agent_id'],
|
|
@@ -739,6 +796,32 @@ High hit rate memories (frequently fetched after being indexed) are good candida
|
|
|
739
796
|
required: ['task_id', 'agent_id'],
|
|
740
797
|
},
|
|
741
798
|
},
|
|
799
|
+
{
|
|
800
|
+
name: 'task_handoff',
|
|
801
|
+
description: 'Hand off a task to a different expert worker type during execution. Releases current lock, updates agent_type, and sets task back to ready status for re-dispatch.',
|
|
802
|
+
inputSchema: {
|
|
803
|
+
type: 'object',
|
|
804
|
+
properties: {
|
|
805
|
+
task_id: {
|
|
806
|
+
type: 'string',
|
|
807
|
+
description: 'The task ID to hand off',
|
|
808
|
+
},
|
|
809
|
+
new_worker_type: {
|
|
810
|
+
type: 'string',
|
|
811
|
+
description: 'Target worker type (e.g., "api-expert", "frontend-expert", "database-expert")',
|
|
812
|
+
},
|
|
813
|
+
reason: {
|
|
814
|
+
type: 'string',
|
|
815
|
+
description: 'Why the handoff is needed (e.g., "requires database expertise", "needs API integration")',
|
|
816
|
+
},
|
|
817
|
+
agent_id: {
|
|
818
|
+
type: 'string',
|
|
819
|
+
description: 'Your agent identifier (default: claude-code)',
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
required: ['task_id', 'new_worker_type', 'reason'],
|
|
823
|
+
},
|
|
824
|
+
},
|
|
742
825
|
// =========================================================================
|
|
743
826
|
// ORCHESTRATION TOOLS
|
|
744
827
|
// =========================================================================
|
|
@@ -921,6 +1004,24 @@ High hit rate memories (frequently fetched after being indexed) are good candida
|
|
|
921
1004
|
required: ['epic_id'],
|
|
922
1005
|
},
|
|
923
1006
|
},
|
|
1007
|
+
{
|
|
1008
|
+
name: 'task_aggregate',
|
|
1009
|
+
description: 'Aggregate work_log context from all sibling tasks under an epic for orchestrator synthesis. Returns structured summary of completed work across all tasks in the epic.',
|
|
1010
|
+
inputSchema: {
|
|
1011
|
+
type: 'object',
|
|
1012
|
+
properties: {
|
|
1013
|
+
epic_id: {
|
|
1014
|
+
type: 'string',
|
|
1015
|
+
description: 'Epic ID to aggregate from',
|
|
1016
|
+
},
|
|
1017
|
+
include_pending: {
|
|
1018
|
+
type: 'boolean',
|
|
1019
|
+
description: 'Include non-completed tasks (default: false)',
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
required: ['epic_id'],
|
|
1023
|
+
},
|
|
1024
|
+
},
|
|
924
1025
|
// =========================================================================
|
|
925
1026
|
// CODEBASE MAPPING TOOLS
|
|
926
1027
|
// =========================================================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claudetools/tools",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
4
4
|
"description": "Persistent AI memory, task management, and codebase intelligence for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -14,22 +14,6 @@
|
|
|
14
14
|
"README.md",
|
|
15
15
|
"LICENSE"
|
|
16
16
|
],
|
|
17
|
-
"scripts": {
|
|
18
|
-
"build": "tsc",
|
|
19
|
-
"start": "node dist/index.js",
|
|
20
|
-
"dev": "tsx src/index.ts",
|
|
21
|
-
"test": "vitest run",
|
|
22
|
-
"test:watch": "vitest",
|
|
23
|
-
"test:ui": "vitest --ui",
|
|
24
|
-
"prepublishOnly": "npm run build",
|
|
25
|
-
"codedna:monitor": "tsx -e \"import { runMonitoring } from './src/helpers/codedna-monitoring.js'; runMonitoring()\"",
|
|
26
|
-
"codedna:analytics": "tsx -e \"import { weeklyAnalyticsSummary } from './src/helpers/usage-analytics.js'; weeklyAnalyticsSummary()\"",
|
|
27
|
-
"codedna:analytics:24h": "tsx -e \"import { getLast24HoursAnalytics, printAnalytics } from './src/helpers/usage-analytics.js'; const r = await getLast24HoursAnalytics(); printAnalytics(r, 'Last 24 Hours')\"",
|
|
28
|
-
"codedna:analytics:30d": "tsx -e \"import { getLast30DaysAnalytics, printAnalytics } from './src/helpers/usage-analytics.js'; const r = await getLast30DaysAnalytics(); printAnalytics(r, 'Last 30 Days')\"",
|
|
29
|
-
"prompt:verify": "scripts/verify-prompt-compliance.sh",
|
|
30
|
-
"eval:build-dataset": "tsx src/evaluation/build-dataset.ts",
|
|
31
|
-
"eval:threshold": "tsx src/evaluation/threshold-eval.ts"
|
|
32
|
-
},
|
|
33
17
|
"repository": {
|
|
34
18
|
"type": "git",
|
|
35
19
|
"url": "git+https://github.com/claudetools/memory.git"
|
|
@@ -74,5 +58,20 @@
|
|
|
74
58
|
"tsx": "^4.7.0",
|
|
75
59
|
"typescript": "^5.3.0",
|
|
76
60
|
"vitest": "^4.0.15"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsc",
|
|
64
|
+
"start": "node dist/index.js",
|
|
65
|
+
"dev": "tsx src/index.ts",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"test:watch": "vitest",
|
|
68
|
+
"test:ui": "vitest --ui",
|
|
69
|
+
"codedna:monitor": "tsx -e \"import { runMonitoring } from './src/helpers/codedna-monitoring.js'; runMonitoring()\"",
|
|
70
|
+
"codedna:analytics": "tsx -e \"import { weeklyAnalyticsSummary } from './src/helpers/usage-analytics.js'; weeklyAnalyticsSummary()\"",
|
|
71
|
+
"codedna:analytics:24h": "tsx -e \"import { getLast24HoursAnalytics, printAnalytics } from './src/helpers/usage-analytics.js'; const r = await getLast24HoursAnalytics(); printAnalytics(r, 'Last 24 Hours')\"",
|
|
72
|
+
"codedna:analytics:30d": "tsx -e \"import { getLast30DaysAnalytics, printAnalytics } from './src/helpers/usage-analytics.js'; const r = await getLast30DaysAnalytics(); printAnalytics(r, 'Last 30 Days')\"",
|
|
73
|
+
"prompt:verify": "scripts/verify-prompt-compliance.sh",
|
|
74
|
+
"eval:build-dataset": "tsx src/evaluation/build-dataset.ts",
|
|
75
|
+
"eval:threshold": "tsx src/evaluation/threshold-eval.ts"
|
|
77
76
|
}
|
|
78
|
-
}
|
|
77
|
+
}
|
|
File without changes
|