@desplega.ai/agent-swarm 1.83.1 → 1.83.2
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/openapi.json +139 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
package/src/tools/task-action.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
3
|
import * as z from "zod";
|
|
3
4
|
import { canClaim } from "@/be/budget-admission";
|
|
4
5
|
import {
|
|
@@ -24,10 +25,11 @@ import {
|
|
|
24
25
|
releaseTask,
|
|
25
26
|
updateTaskClaudeSessionId,
|
|
26
27
|
} from "@/be/db";
|
|
28
|
+
import { assertOwnsTask, ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
|
|
27
29
|
import { createToolRegistrar } from "@/tools/utils";
|
|
28
30
|
import { AgentTaskSchema, BudgetRefusalCauseSchema } from "@/types";
|
|
29
31
|
|
|
30
|
-
const TaskActionSchema = z.enum([
|
|
32
|
+
export const TaskActionSchema = z.enum([
|
|
31
33
|
"create",
|
|
32
34
|
"claim",
|
|
33
35
|
"release",
|
|
@@ -37,400 +39,495 @@ const TaskActionSchema = z.enum([
|
|
|
37
39
|
"from_backlog",
|
|
38
40
|
]);
|
|
39
41
|
|
|
40
|
-
export const
|
|
41
|
-
|
|
42
|
-
"task
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
42
|
+
export const taskActionInputSchema = z.object({
|
|
43
|
+
action: TaskActionSchema.describe(
|
|
44
|
+
"The action to perform: 'create' creates an unassigned task, 'claim' takes a task from pool, 'release' returns task to pool, 'accept' accepts offered task, 'reject' declines offered task, 'to_backlog' moves task to backlog, 'from_backlog' moves task from backlog to pool.",
|
|
45
|
+
),
|
|
46
|
+
// For 'create' action:
|
|
47
|
+
task: z.string().min(1).optional().describe("Task description (required for 'create')."),
|
|
48
|
+
taskType: z.string().max(50).optional().describe("Task type (e.g., 'bug', 'feature')."),
|
|
49
|
+
tags: z
|
|
50
|
+
.array(z.string())
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Tags for filtering (e.g., ['urgent', 'frontend'])."),
|
|
53
|
+
priority: z.number().int().min(0).max(100).optional().describe("Priority 0-100, default 50."),
|
|
54
|
+
dependsOn: z.array(z.uuid()).optional().describe("Task IDs this task depends on."),
|
|
55
|
+
// For claim/release/accept/reject actions:
|
|
56
|
+
taskId: z.uuid().optional().describe("Task ID (required for claim/release/accept/reject)."),
|
|
57
|
+
// For 'reject' action:
|
|
58
|
+
reason: z.string().optional().describe("Reason for rejection (optional for 'reject')."),
|
|
59
|
+
// For 'create' action:
|
|
60
|
+
dir: z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1)
|
|
63
|
+
.startsWith("/")
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
"Working directory (absolute path) for the agent to start in. Only used with 'create' action.",
|
|
67
|
+
),
|
|
68
|
+
model: z
|
|
69
|
+
.enum(["haiku", "sonnet", "opus"])
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
"Model to use for the created task ('haiku', 'sonnet', or 'opus'). Only used with 'create' action.",
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const taskActionOutputSchema = z.object({
|
|
77
|
+
yourAgentId: z.string().uuid().optional(),
|
|
78
|
+
success: z.boolean(),
|
|
79
|
+
message: z.string(),
|
|
80
|
+
task: AgentTaskSchema.optional(),
|
|
81
|
+
// Phase 3: budget-admission refusal fields. Populated only on
|
|
82
|
+
// `accept` action when the per-agent or global daily budget is blown.
|
|
83
|
+
refusalCause: BudgetRefusalCauseSchema.optional(),
|
|
84
|
+
agentSpend: z.number().optional(),
|
|
85
|
+
agentBudget: z.number().optional(),
|
|
86
|
+
globalSpend: z.number().optional(),
|
|
87
|
+
globalBudget: z.number().optional(),
|
|
88
|
+
userSpend: z.number().optional(),
|
|
89
|
+
userBudget: z.number().optional(),
|
|
90
|
+
resetAt: z.string().optional(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
type TaskActionArgs = z.infer<typeof taskActionInputSchema>;
|
|
94
|
+
type TaskActionResult = {
|
|
95
|
+
success: boolean;
|
|
96
|
+
message: string;
|
|
97
|
+
task?: unknown;
|
|
98
|
+
refusalCause?: unknown;
|
|
99
|
+
agentSpend?: number;
|
|
100
|
+
agentBudget?: number;
|
|
101
|
+
globalSpend?: number;
|
|
102
|
+
globalBudget?: number;
|
|
103
|
+
userSpend?: number;
|
|
104
|
+
userBudget?: number;
|
|
105
|
+
resetAt?: string;
|
|
106
|
+
refusalSideEffects?: unknown;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function agentOnlyActionResult(): CallToolResult {
|
|
110
|
+
const message = "This action is only available to worker agents.";
|
|
111
|
+
return {
|
|
112
|
+
isError: true,
|
|
113
|
+
content: [{ type: "text", text: message }],
|
|
114
|
+
structuredContent: {
|
|
115
|
+
success: false,
|
|
116
|
+
message,
|
|
101
117
|
},
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
input;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
105
120
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
function taskActionCallResult(result: TaskActionResult, agentId?: string): CallToolResult {
|
|
122
|
+
const { refusalSideEffects: _omit, ...publicResult } = result;
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: "text", text: result.message }],
|
|
125
|
+
structuredContent: {
|
|
126
|
+
yourAgentId: agentId,
|
|
127
|
+
...publicResult,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function taskActionHandler(
|
|
133
|
+
ctx: ToolCtx,
|
|
134
|
+
input: TaskActionArgs,
|
|
135
|
+
): Promise<CallToolResult> {
|
|
136
|
+
const { action, task, taskType, tags, priority, dependsOn, taskId, reason, dir, model } = input;
|
|
137
|
+
|
|
138
|
+
if (ctx.kind === "user") {
|
|
139
|
+
if (action !== "to_backlog" && action !== "from_backlog") {
|
|
140
|
+
return agentOnlyActionResult();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result = getDb().transaction((): TaskActionResult | CallToolResult => {
|
|
144
|
+
if (!taskId) {
|
|
145
|
+
return { success: false, message: `Task ID is required for '${action}' action.` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const existingTask = getTaskById(taskId);
|
|
149
|
+
if (!existingTask) {
|
|
150
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ownershipError = assertOwnsTask(ctx, existingTask);
|
|
154
|
+
if (ownershipError) return ownershipError;
|
|
155
|
+
|
|
156
|
+
if (action === "to_backlog") {
|
|
157
|
+
if (existingTask.status !== "unassigned") {
|
|
158
|
+
return {
|
|
110
159
|
success: false,
|
|
111
|
-
message:
|
|
112
|
-
}
|
|
160
|
+
message: `Task "${taskId}" is not unassigned (status: ${existingTask.status}). Only unassigned tasks can be moved to backlog.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const backlogTask = moveTaskToBacklog(taskId);
|
|
164
|
+
if (!backlogTask) {
|
|
165
|
+
return { success: false, message: `Failed to move task "${taskId}" to backlog.` };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
success: true,
|
|
169
|
+
message: `Moved task "${taskId}" to backlog.`,
|
|
170
|
+
task: backlogTask,
|
|
113
171
|
};
|
|
114
172
|
}
|
|
115
173
|
|
|
116
|
-
|
|
174
|
+
if (existingTask.status !== "backlog") {
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
message: `Task "${taskId}" is not in backlog (status: ${existingTask.status}).`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const unassignedTask = moveTaskFromBacklog(taskId);
|
|
181
|
+
if (!unassignedTask) {
|
|
182
|
+
return { success: false, message: `Failed to move task "${taskId}" from backlog.` };
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
message: `Moved task "${taskId}" from backlog to pool.`,
|
|
187
|
+
task: unassignedTask,
|
|
188
|
+
};
|
|
189
|
+
})();
|
|
190
|
+
|
|
191
|
+
return "content" in result ? result : taskActionCallResult(result);
|
|
192
|
+
}
|
|
117
193
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const newTask = createTaskExtended(task, {
|
|
128
|
-
creatorAgentId: agentId,
|
|
129
|
-
taskType,
|
|
130
|
-
tags,
|
|
131
|
-
priority,
|
|
132
|
-
dependsOn,
|
|
133
|
-
dir,
|
|
134
|
-
model,
|
|
135
|
-
});
|
|
136
|
-
return {
|
|
137
|
-
success: true,
|
|
138
|
-
message: `Created unassigned task "${newTask.id}".`,
|
|
139
|
-
task: newTask,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
194
|
+
if (!ctx.agentId) {
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: "text", text: 'Agent ID not found. Set the "X-Agent-ID" header.' }],
|
|
197
|
+
structuredContent: {
|
|
198
|
+
success: false,
|
|
199
|
+
message: 'Agent ID not found. Set the "X-Agent-ID" header.',
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
142
203
|
|
|
143
|
-
|
|
144
|
-
if (!taskId) {
|
|
145
|
-
return { success: false, message: "Task ID is required for 'claim' action." };
|
|
146
|
-
}
|
|
147
|
-
// Check capacity before claiming
|
|
148
|
-
if (!hasCapacity(agentId)) {
|
|
149
|
-
const activeCount = getActiveTaskCount(agentId);
|
|
150
|
-
const agent = getAgentById(agentId);
|
|
151
|
-
return {
|
|
152
|
-
success: false,
|
|
153
|
-
message: `You have no capacity (${activeCount}/${agent?.maxTasks ?? 1} tasks). Complete a task first.`,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
// Pre-checks for informative error messages (the atomic UPDATE in
|
|
157
|
-
// claimTask is the real guard against race conditions)
|
|
158
|
-
const existingTask = getTaskById(taskId);
|
|
159
|
-
if (!existingTask) {
|
|
160
|
-
return { success: false, message: `Task "${taskId}" not found.` };
|
|
161
|
-
}
|
|
162
|
-
if (existingTask.status !== "unassigned") {
|
|
163
|
-
return {
|
|
164
|
-
success: false,
|
|
165
|
-
message: `Task "${taskId}" is not unassigned (status: ${existingTask.status}). It may have been claimed by another agent.`,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
// Check if task dependencies are met
|
|
169
|
-
const { ready, blockedBy } = checkDependencies(taskId);
|
|
170
|
-
if (!ready) {
|
|
171
|
-
return {
|
|
172
|
-
success: false,
|
|
173
|
-
message: `Task "${taskId}" has unmet dependencies: ${blockedBy.join(", ")}. Cannot claim until dependencies are completed.`,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
// Atomic claim — only one agent can win this race
|
|
177
|
-
const claimedTask = claimTask(taskId, agentId);
|
|
178
|
-
if (!claimedTask) {
|
|
179
|
-
return {
|
|
180
|
-
success: false,
|
|
181
|
-
message: `Task "${taskId}" was already claimed by another agent. Try a different task.`,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
204
|
+
const agentId = ctx.agentId;
|
|
184
205
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
const txn = getDb().transaction((): TaskActionResult => {
|
|
207
|
+
switch (action) {
|
|
208
|
+
case "create": {
|
|
209
|
+
if (!task) {
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
message: "Task description is required for 'create' action.",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const newTask = createTaskExtended(task, {
|
|
216
|
+
creatorAgentId: agentId,
|
|
217
|
+
taskType,
|
|
218
|
+
tags,
|
|
219
|
+
priority,
|
|
220
|
+
dependsOn,
|
|
221
|
+
dir,
|
|
222
|
+
model,
|
|
223
|
+
});
|
|
224
|
+
return {
|
|
225
|
+
success: true,
|
|
226
|
+
message: `Created unassigned task "${newTask.id}".`,
|
|
227
|
+
task: newTask,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
200
230
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
231
|
+
case "claim": {
|
|
232
|
+
if (!taskId) {
|
|
233
|
+
return { success: false, message: "Task ID is required for 'claim' action." };
|
|
234
|
+
}
|
|
235
|
+
// Check capacity before claiming
|
|
236
|
+
if (!hasCapacity(agentId)) {
|
|
237
|
+
const activeCount = getActiveTaskCount(agentId);
|
|
238
|
+
const agent = getAgentById(agentId);
|
|
239
|
+
return {
|
|
240
|
+
success: false,
|
|
241
|
+
message: `You have no capacity (${activeCount}/${agent?.maxTasks ?? 1} tasks). Complete a task first.`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Pre-checks for informative error messages (the atomic UPDATE in
|
|
245
|
+
// claimTask is the real guard against race conditions)
|
|
246
|
+
const existingTask = getTaskById(taskId);
|
|
247
|
+
if (!existingTask) {
|
|
248
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
249
|
+
}
|
|
250
|
+
if (existingTask.status !== "unassigned") {
|
|
251
|
+
return {
|
|
252
|
+
success: false,
|
|
253
|
+
message: `Task "${taskId}" is not unassigned (status: ${existingTask.status}). It may have been claimed by another agent.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// Check if task dependencies are met
|
|
257
|
+
const { ready, blockedBy } = checkDependencies(taskId);
|
|
258
|
+
if (!ready) {
|
|
259
|
+
return {
|
|
260
|
+
success: false,
|
|
261
|
+
message: `Task "${taskId}" has unmet dependencies: ${blockedBy.join(", ")}. Cannot claim until dependencies are completed.`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// Atomic claim — only one agent can win this race
|
|
265
|
+
const claimedTask = claimTask(taskId, agentId);
|
|
266
|
+
if (!claimedTask) {
|
|
267
|
+
return {
|
|
268
|
+
success: false,
|
|
269
|
+
message: `Task "${taskId}" was already claimed by another agent. Try a different task.`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
207
272
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return { success: false, message: `Task "${taskId}" is not assigned to you.` };
|
|
218
|
-
}
|
|
219
|
-
if (existingTask.status !== "pending" && existingTask.status !== "in_progress") {
|
|
220
|
-
return {
|
|
221
|
-
success: false,
|
|
222
|
-
message: `Cannot release task in status "${existingTask.status}". Only 'pending' or 'in_progress' tasks can be released.`,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
const releasedTask = releaseTask(taskId);
|
|
226
|
-
if (!releasedTask) {
|
|
227
|
-
return { success: false, message: `Failed to release task "${taskId}".` };
|
|
228
|
-
}
|
|
229
|
-
return {
|
|
230
|
-
success: true,
|
|
231
|
-
message: `Released task "${taskId}" back to pool.`,
|
|
232
|
-
task: releasedTask,
|
|
233
|
-
};
|
|
273
|
+
// Reassociate session logs from pool trigger's random UUID to real task ID
|
|
274
|
+
const sessions = getActiveSessions(agentId);
|
|
275
|
+
const activeSession = sessions.find((s) => s.runnerSessionId);
|
|
276
|
+
if (activeSession?.runnerSessionId) {
|
|
277
|
+
const count = reassociateSessionLogs(activeSession.runnerSessionId, taskId);
|
|
278
|
+
if (count > 0) {
|
|
279
|
+
console.log(
|
|
280
|
+
`[task-action] Reassociated ${count} session logs for claimed task ${taskId.slice(0, 8)}`,
|
|
281
|
+
);
|
|
234
282
|
}
|
|
283
|
+
// Propagate provider session ID (e.g. claudeSessionId) to the task
|
|
284
|
+
if (activeSession.providerSessionId) {
|
|
285
|
+
updateTaskClaudeSessionId(taskId, activeSession.providerSessionId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
235
288
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
289
|
+
return {
|
|
290
|
+
success: true,
|
|
291
|
+
message: `Claimed task "${taskId}".`,
|
|
292
|
+
task: claimedTask,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "release": {
|
|
297
|
+
if (!taskId) {
|
|
298
|
+
return { success: false, message: "Task ID is required for 'release' action." };
|
|
299
|
+
}
|
|
300
|
+
const existingTask = getTaskById(taskId);
|
|
301
|
+
if (!existingTask) {
|
|
302
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
303
|
+
}
|
|
304
|
+
if (existingTask.agentId !== agentId) {
|
|
305
|
+
return { success: false, message: `Task "${taskId}" is not assigned to you.` };
|
|
306
|
+
}
|
|
307
|
+
if (existingTask.status !== "pending" && existingTask.status !== "in_progress") {
|
|
308
|
+
return {
|
|
309
|
+
success: false,
|
|
310
|
+
message: `Cannot release task in status "${existingTask.status}". Only 'pending' or 'in_progress' tasks can be released.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const releasedTask = releaseTask(taskId);
|
|
314
|
+
if (!releasedTask) {
|
|
315
|
+
return { success: false, message: `Failed to release task "${taskId}".` };
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
success: true,
|
|
319
|
+
message: `Released task "${taskId}" back to pool.`,
|
|
320
|
+
task: releasedTask,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case "accept": {
|
|
325
|
+
if (!taskId) {
|
|
326
|
+
return { success: false, message: "Task ID is required for 'accept' action." };
|
|
327
|
+
}
|
|
328
|
+
const existingTask = getTaskById(taskId);
|
|
329
|
+
if (!existingTask) {
|
|
330
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
331
|
+
}
|
|
332
|
+
if (existingTask.status !== "offered") {
|
|
333
|
+
return { success: false, message: `Task "${taskId}" is not offered.` };
|
|
334
|
+
}
|
|
335
|
+
if (existingTask.offeredTo !== agentId) {
|
|
336
|
+
return { success: false, message: `Task "${taskId}" was not offered to you.` };
|
|
337
|
+
}
|
|
338
|
+
// Check if task dependencies are met
|
|
339
|
+
const { ready, blockedBy } = checkDependencies(taskId);
|
|
340
|
+
if (!ready) {
|
|
341
|
+
return {
|
|
342
|
+
success: false,
|
|
343
|
+
message: `Task "${taskId}" has unmet dependencies: ${blockedBy.join(", ")}. Cannot accept until dependencies are completed.`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// Budget admission gate (Phase 3). Same in-transaction placement
|
|
347
|
+
// as the /api/poll gates so capacity AND budget share atomicity.
|
|
348
|
+
// Phase 5: record dedup row + capture side-effect context for the
|
|
349
|
+
// after-commit lead follow-up + workflow event-bus emit.
|
|
350
|
+
const admission = canClaim(agentId, new Date(), existingTask.requestedByUserId);
|
|
351
|
+
if (!admission.allowed) {
|
|
352
|
+
const causeMsg =
|
|
353
|
+
admission.cause === "agent"
|
|
354
|
+
? "agent daily budget exceeded"
|
|
355
|
+
: admission.cause === "user"
|
|
356
|
+
? "user daily budget exceeded"
|
|
357
|
+
: "global daily budget exceeded";
|
|
358
|
+
const utcDate = new Date().toISOString().slice(0, 10);
|
|
359
|
+
const dedup = recordBudgetRefusalNotification({
|
|
360
|
+
taskId,
|
|
361
|
+
date: utcDate,
|
|
362
|
+
agentId,
|
|
363
|
+
cause: admission.cause,
|
|
364
|
+
agentSpendUsd: admission.agentSpend,
|
|
365
|
+
agentBudgetUsd: admission.agentBudget,
|
|
366
|
+
globalSpendUsd: admission.globalSpend,
|
|
367
|
+
globalBudgetUsd: admission.globalBudget,
|
|
368
|
+
userSpendUsd: admission.userSpend,
|
|
369
|
+
userBudgetUsd: admission.userBudget,
|
|
370
|
+
});
|
|
371
|
+
return {
|
|
372
|
+
success: false,
|
|
373
|
+
message: `Refused: ${causeMsg}. Resets at ${admission.resetAt}.`,
|
|
374
|
+
refusalCause: admission.cause,
|
|
375
|
+
...(admission.agentSpend !== undefined && { agentSpend: admission.agentSpend }),
|
|
376
|
+
...(admission.agentBudget !== undefined && { agentBudget: admission.agentBudget }),
|
|
377
|
+
...(admission.globalSpend !== undefined && { globalSpend: admission.globalSpend }),
|
|
378
|
+
...(admission.globalBudget !== undefined && {
|
|
379
|
+
globalBudget: admission.globalBudget,
|
|
380
|
+
}),
|
|
381
|
+
...(admission.userSpend !== undefined && { userSpend: admission.userSpend }),
|
|
382
|
+
...(admission.userBudget !== undefined && { userBudget: admission.userBudget }),
|
|
383
|
+
resetAt: admission.resetAt,
|
|
384
|
+
refusalSideEffects: {
|
|
385
|
+
context: {
|
|
386
|
+
task: {
|
|
387
|
+
id: existingTask.id,
|
|
388
|
+
task: existingTask.task,
|
|
389
|
+
requestedByUserId: existingTask.requestedByUserId,
|
|
390
|
+
slackChannelId: existingTask.slackChannelId,
|
|
391
|
+
slackThreadTs: existingTask.slackThreadTs,
|
|
392
|
+
slackUserId: existingTask.slackUserId,
|
|
393
|
+
},
|
|
272
394
|
agentId,
|
|
395
|
+
date: utcDate,
|
|
273
396
|
cause: admission.cause,
|
|
274
397
|
agentSpendUsd: admission.agentSpend,
|
|
275
398
|
agentBudgetUsd: admission.agentBudget,
|
|
276
399
|
globalSpendUsd: admission.globalSpend,
|
|
277
400
|
globalBudgetUsd: admission.globalBudget,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
success: false,
|
|
281
|
-
message: `Refused: ${causeMsg}. Resets at ${admission.resetAt}.`,
|
|
282
|
-
refusalCause: admission.cause,
|
|
283
|
-
...(admission.agentSpend !== undefined && { agentSpend: admission.agentSpend }),
|
|
284
|
-
...(admission.agentBudget !== undefined && { agentBudget: admission.agentBudget }),
|
|
285
|
-
...(admission.globalSpend !== undefined && { globalSpend: admission.globalSpend }),
|
|
286
|
-
...(admission.globalBudget !== undefined && {
|
|
287
|
-
globalBudget: admission.globalBudget,
|
|
288
|
-
}),
|
|
401
|
+
userSpendUsd: admission.userSpend,
|
|
402
|
+
userBudgetUsd: admission.userBudget,
|
|
289
403
|
resetAt: admission.resetAt,
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
globalBudgetUsd: admission.globalBudget,
|
|
306
|
-
resetAt: admission.resetAt,
|
|
307
|
-
} satisfies BudgetRefusalContext,
|
|
308
|
-
inserted: dedup.inserted,
|
|
309
|
-
},
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
const acceptedTask = acceptTask(taskId, agentId);
|
|
313
|
-
if (!acceptedTask) {
|
|
314
|
-
return { success: false, message: `Failed to accept task "${taskId}".` };
|
|
315
|
-
}
|
|
316
|
-
return {
|
|
317
|
-
success: true,
|
|
318
|
-
message: `Accepted task "${taskId}".`,
|
|
319
|
-
task: acceptedTask,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
case "reject": {
|
|
324
|
-
if (!taskId) {
|
|
325
|
-
return { success: false, message: "Task ID is required for 'reject' action." };
|
|
326
|
-
}
|
|
327
|
-
const existingTask = getTaskById(taskId);
|
|
328
|
-
if (!existingTask) {
|
|
329
|
-
return { success: false, message: `Task "${taskId}" not found.` };
|
|
330
|
-
}
|
|
331
|
-
if (existingTask.status !== "offered") {
|
|
332
|
-
return { success: false, message: `Task "${taskId}" is not offered.` };
|
|
333
|
-
}
|
|
334
|
-
if (existingTask.offeredTo !== agentId) {
|
|
335
|
-
return { success: false, message: `Task "${taskId}" was not offered to you.` };
|
|
336
|
-
}
|
|
337
|
-
const rejectedTask = rejectTask(taskId, agentId, reason);
|
|
338
|
-
if (!rejectedTask) {
|
|
339
|
-
return { success: false, message: `Failed to reject task "${taskId}".` };
|
|
340
|
-
}
|
|
341
|
-
return {
|
|
342
|
-
success: true,
|
|
343
|
-
message: `Rejected task "${taskId}". Task returned to pool.`,
|
|
344
|
-
task: rejectedTask,
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
case "to_backlog": {
|
|
349
|
-
if (!taskId) {
|
|
350
|
-
return { success: false, message: "Task ID is required for 'to_backlog' action." };
|
|
351
|
-
}
|
|
352
|
-
const existingTask = getTaskById(taskId);
|
|
353
|
-
if (!existingTask) {
|
|
354
|
-
return { success: false, message: `Task "${taskId}" not found.` };
|
|
355
|
-
}
|
|
356
|
-
if (existingTask.status !== "unassigned") {
|
|
357
|
-
return {
|
|
358
|
-
success: false,
|
|
359
|
-
message: `Task "${taskId}" is not unassigned (status: ${existingTask.status}). Only unassigned tasks can be moved to backlog.`,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
const backlogTask = moveTaskToBacklog(taskId);
|
|
363
|
-
if (!backlogTask) {
|
|
364
|
-
return { success: false, message: `Failed to move task "${taskId}" to backlog.` };
|
|
365
|
-
}
|
|
366
|
-
return {
|
|
367
|
-
success: true,
|
|
368
|
-
message: `Moved task "${taskId}" to backlog.`,
|
|
369
|
-
task: backlogTask,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
case "from_backlog": {
|
|
374
|
-
if (!taskId) {
|
|
375
|
-
return { success: false, message: "Task ID is required for 'from_backlog' action." };
|
|
376
|
-
}
|
|
377
|
-
const existingTask = getTaskById(taskId);
|
|
378
|
-
if (!existingTask) {
|
|
379
|
-
return { success: false, message: `Task "${taskId}" not found.` };
|
|
380
|
-
}
|
|
381
|
-
if (existingTask.status !== "backlog") {
|
|
382
|
-
return {
|
|
383
|
-
success: false,
|
|
384
|
-
message: `Task "${taskId}" is not in backlog (status: ${existingTask.status}).`,
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
const unassignedTask = moveTaskFromBacklog(taskId);
|
|
388
|
-
if (!unassignedTask) {
|
|
389
|
-
return { success: false, message: `Failed to move task "${taskId}" from backlog.` };
|
|
390
|
-
}
|
|
391
|
-
return {
|
|
392
|
-
success: true,
|
|
393
|
-
message: `Moved task "${taskId}" from backlog to pool.`,
|
|
394
|
-
task: unassignedTask,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
404
|
+
} satisfies BudgetRefusalContext,
|
|
405
|
+
inserted: dedup.inserted,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const acceptedTask = acceptTask(taskId, agentId);
|
|
410
|
+
if (!acceptedTask) {
|
|
411
|
+
return { success: false, message: `Failed to accept task "${taskId}".` };
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
success: true,
|
|
415
|
+
message: `Accepted task "${taskId}".`,
|
|
416
|
+
task: acceptedTask,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
397
419
|
|
|
398
|
-
|
|
399
|
-
|
|
420
|
+
case "reject": {
|
|
421
|
+
if (!taskId) {
|
|
422
|
+
return { success: false, message: "Task ID is required for 'reject' action." };
|
|
423
|
+
}
|
|
424
|
+
const existingTask = getTaskById(taskId);
|
|
425
|
+
if (!existingTask) {
|
|
426
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
427
|
+
}
|
|
428
|
+
if (existingTask.status !== "offered") {
|
|
429
|
+
return { success: false, message: `Task "${taskId}" is not offered.` };
|
|
400
430
|
}
|
|
401
|
-
|
|
431
|
+
if (existingTask.offeredTo !== agentId) {
|
|
432
|
+
return { success: false, message: `Task "${taskId}" was not offered to you.` };
|
|
433
|
+
}
|
|
434
|
+
const rejectedTask = rejectTask(taskId, agentId, reason);
|
|
435
|
+
if (!rejectedTask) {
|
|
436
|
+
return { success: false, message: `Failed to reject task "${taskId}".` };
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
success: true,
|
|
440
|
+
message: `Rejected task "${taskId}". Task returned to pool.`,
|
|
441
|
+
task: rejectedTask,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
402
444
|
|
|
403
|
-
|
|
445
|
+
case "to_backlog": {
|
|
446
|
+
if (!taskId) {
|
|
447
|
+
return { success: false, message: "Task ID is required for 'to_backlog' action." };
|
|
448
|
+
}
|
|
449
|
+
const existingTask = getTaskById(taskId);
|
|
450
|
+
if (!existingTask) {
|
|
451
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
452
|
+
}
|
|
453
|
+
if (existingTask.status !== "unassigned") {
|
|
454
|
+
return {
|
|
455
|
+
success: false,
|
|
456
|
+
message: `Task "${taskId}" is not unassigned (status: ${existingTask.status}). Only unassigned tasks can be moved to backlog.`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const backlogTask = moveTaskToBacklog(taskId);
|
|
460
|
+
if (!backlogTask) {
|
|
461
|
+
return { success: false, message: `Failed to move task "${taskId}" to backlog.` };
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
success: true,
|
|
465
|
+
message: `Moved task "${taskId}" to backlog.`,
|
|
466
|
+
task: backlogTask,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
404
469
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
470
|
+
case "from_backlog": {
|
|
471
|
+
if (!taskId) {
|
|
472
|
+
return { success: false, message: "Task ID is required for 'from_backlog' action." };
|
|
473
|
+
}
|
|
474
|
+
const existingTask = getTaskById(taskId);
|
|
475
|
+
if (!existingTask) {
|
|
476
|
+
return { success: false, message: `Task "${taskId}" not found.` };
|
|
477
|
+
}
|
|
478
|
+
if (existingTask.status !== "backlog") {
|
|
479
|
+
return {
|
|
480
|
+
success: false,
|
|
481
|
+
message: `Task "${taskId}" is not in backlog (status: ${existingTask.status}).`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const unassignedTask = moveTaskFromBacklog(taskId);
|
|
485
|
+
if (!unassignedTask) {
|
|
486
|
+
return { success: false, message: `Failed to move task "${taskId}" from backlog.` };
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
success: true,
|
|
490
|
+
message: `Moved task "${taskId}" from backlog to pool.`,
|
|
491
|
+
task: unassignedTask,
|
|
416
492
|
};
|
|
417
|
-
emitBudgetRefusalSideEffects(sideEffects.context, sideEffects.inserted);
|
|
418
493
|
}
|
|
419
494
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
[key: string]: unknown;
|
|
425
|
-
};
|
|
495
|
+
default:
|
|
496
|
+
return { success: false, message: `Unknown action: ${action}` };
|
|
497
|
+
}
|
|
498
|
+
});
|
|
426
499
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
500
|
+
const result = txn();
|
|
501
|
+
|
|
502
|
+
// Phase 5: when the accept gate refused, run after-commit side
|
|
503
|
+
// effects (lead follow-up + workflow bus). The dedup row was recorded
|
|
504
|
+
// inside the txn; this just consumes the captured context.
|
|
505
|
+
if (
|
|
506
|
+
"refusalSideEffects" in result &&
|
|
507
|
+
result.refusalSideEffects &&
|
|
508
|
+
typeof result.refusalSideEffects === "object"
|
|
509
|
+
) {
|
|
510
|
+
const sideEffects = result.refusalSideEffects as {
|
|
511
|
+
context: BudgetRefusalContext;
|
|
512
|
+
inserted: boolean;
|
|
513
|
+
};
|
|
514
|
+
emitBudgetRefusalSideEffects(sideEffects.context, sideEffects.inserted);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return taskActionCallResult(result, agentId);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export const registerTaskActionTool = (server: McpServer) => {
|
|
521
|
+
createToolRegistrar(server)(
|
|
522
|
+
"task-action",
|
|
523
|
+
{
|
|
524
|
+
title: "Task Pool Actions",
|
|
525
|
+
annotations: { destructiveHint: false },
|
|
526
|
+
description:
|
|
527
|
+
"Perform task pool operations: create unassigned tasks, claim/release tasks from pool, accept/reject offered tasks.",
|
|
528
|
+
inputSchema: taskActionInputSchema,
|
|
529
|
+
outputSchema: taskActionOutputSchema,
|
|
434
530
|
},
|
|
531
|
+
async (args, info, _meta) => taskActionHandler(ownerCtx(info), args),
|
|
435
532
|
);
|
|
436
533
|
};
|