@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.
Files changed (55) hide show
  1. package/openapi.json +139 -8
  2. package/package.json +1 -1
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/jira/client.ts +3 -5
  19. package/src/jira/oauth.ts +1 -0
  20. package/src/jira/sync.ts +2 -2
  21. package/src/oauth/ensure-token.ts +1 -0
  22. package/src/oauth/wrapper.ts +38 -7
  23. package/src/providers/claude-adapter.ts +7 -2
  24. package/src/providers/claude-managed-adapter.ts +1 -1
  25. package/src/providers/codex-adapter.ts +30 -0
  26. package/src/providers/opencode-adapter.ts +149 -14
  27. package/src/providers/pi-mono-adapter.ts +41 -1
  28. package/src/providers/types.ts +1 -1
  29. package/src/server-user.ts +117 -0
  30. package/src/tests/artifact-sdk.test.ts +23 -19
  31. package/src/tests/budget-user-scope.test.ts +376 -0
  32. package/src/tests/claude-managed-adapter.test.ts +6 -0
  33. package/src/tests/codex-adapter.test.ts +192 -0
  34. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  35. package/src/tests/db-queries-oauth.test.ts +43 -0
  36. package/src/tests/ensure-token.test.ts +93 -0
  37. package/src/tests/error-tracker.test.ts +52 -0
  38. package/src/tests/fetch-resolved-env.test.ts +33 -20
  39. package/src/tests/http-users.test.ts +29 -1
  40. package/src/tests/mcp-user-route.test.ts +325 -0
  41. package/src/tests/opencode-adapter.test.ts +75 -0
  42. package/src/tests/pi-mono-adapter.test.ts +21 -1
  43. package/src/tests/rate-limit-event.test.ts +69 -6
  44. package/src/tests/resume-session.test.ts +93 -0
  45. package/src/tests/task-tools-ctx.test.ts +100 -0
  46. package/src/tests/task-tools-ownership.test.ts +167 -0
  47. package/src/tests/user-token-routes.test.ts +221 -0
  48. package/src/tools/cancel-task.ts +137 -83
  49. package/src/tools/get-task-details.ts +73 -59
  50. package/src/tools/get-tasks.ts +134 -126
  51. package/src/tools/send-task.ts +312 -312
  52. package/src/tools/task-action.ts +464 -367
  53. package/src/tools/task-tool-ctx.ts +43 -0
  54. package/src/types.ts +6 -2
  55. package/src/utils/error-tracker.ts +122 -9
@@ -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 registerTaskActionTool = (server: McpServer) => {
41
- createToolRegistrar(server)(
42
- "task-action",
43
- {
44
- title: "Task Pool Actions",
45
- annotations: { destructiveHint: false },
46
- description:
47
- "Perform task pool operations: create unassigned tasks, claim/release tasks from pool, accept/reject offered tasks.",
48
- inputSchema: z.object({
49
- action: TaskActionSchema.describe(
50
- "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.",
51
- ),
52
- // For 'create' action:
53
- task: z.string().min(1).optional().describe("Task description (required for 'create')."),
54
- taskType: z.string().max(50).optional().describe("Task type (e.g., 'bug', 'feature')."),
55
- tags: z
56
- .array(z.string())
57
- .optional()
58
- .describe("Tags for filtering (e.g., ['urgent', 'frontend'])."),
59
- priority: z
60
- .number()
61
- .int()
62
- .min(0)
63
- .max(100)
64
- .optional()
65
- .describe("Priority 0-100, default 50."),
66
- dependsOn: z.array(z.uuid()).optional().describe("Task IDs this task depends on."),
67
- // For claim/release/accept/reject actions:
68
- taskId: z.uuid().optional().describe("Task ID (required for claim/release/accept/reject)."),
69
- // For 'reject' action:
70
- reason: z.string().optional().describe("Reason for rejection (optional for 'reject')."),
71
- // For 'create' action:
72
- dir: z
73
- .string()
74
- .min(1)
75
- .startsWith("/")
76
- .optional()
77
- .describe(
78
- "Working directory (absolute path) for the agent to start in. Only used with 'create' action.",
79
- ),
80
- model: z
81
- .enum(["haiku", "sonnet", "opus"])
82
- .optional()
83
- .describe(
84
- "Model to use for the created task ('haiku', 'sonnet', or 'opus'). Only used with 'create' action.",
85
- ),
86
- }),
87
- outputSchema: z.object({
88
- yourAgentId: z.string().uuid().optional(),
89
- success: z.boolean(),
90
- message: z.string(),
91
- task: AgentTaskSchema.optional(),
92
- // Phase 3: budget-admission refusal fields. Populated only on
93
- // `accept` action when the per-agent or global daily budget is blown.
94
- refusalCause: BudgetRefusalCauseSchema.optional(),
95
- agentSpend: z.number().optional(),
96
- agentBudget: z.number().optional(),
97
- globalSpend: z.number().optional(),
98
- globalBudget: z.number().optional(),
99
- resetAt: z.string().optional(),
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
- async (input, requestInfo, _meta) => {
103
- const { action, task, taskType, tags, priority, dependsOn, taskId, reason, dir, model } =
104
- input;
118
+ };
119
+ }
105
120
 
106
- if (!requestInfo.agentId) {
107
- return {
108
- content: [{ type: "text", text: 'Agent ID not found. Set the "X-Agent-ID" header.' }],
109
- structuredContent: {
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: 'Agent ID not found. Set the "X-Agent-ID" header.',
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
- const agentId = requestInfo.agentId;
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
- const txn = getDb().transaction(() => {
119
- switch (action) {
120
- case "create": {
121
- if (!task) {
122
- return {
123
- success: false,
124
- message: "Task description is required for 'create' action.",
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
- case "claim": {
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
- // Reassociate session logs from pool trigger's random UUID to real task ID
186
- const sessions = getActiveSessions(agentId);
187
- const activeSession = sessions.find((s) => s.runnerSessionId);
188
- if (activeSession?.runnerSessionId) {
189
- const count = reassociateSessionLogs(activeSession.runnerSessionId, taskId);
190
- if (count > 0) {
191
- console.log(
192
- `[task-action] Reassociated ${count} session logs for claimed task ${taskId.slice(0, 8)}`,
193
- );
194
- }
195
- // Propagate provider session ID (e.g. claudeSessionId) to the task
196
- if (activeSession.providerSessionId) {
197
- updateTaskClaudeSessionId(taskId, activeSession.providerSessionId);
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
- return {
202
- success: true,
203
- message: `Claimed task "${taskId}".`,
204
- task: claimedTask,
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
- case "release": {
209
- if (!taskId) {
210
- return { success: false, message: "Task ID is required for 'release' action." };
211
- }
212
- const existingTask = getTaskById(taskId);
213
- if (!existingTask) {
214
- return { success: false, message: `Task "${taskId}" not found.` };
215
- }
216
- if (existingTask.agentId !== agentId) {
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
- case "accept": {
237
- if (!taskId) {
238
- return { success: false, message: "Task ID is required for 'accept' action." };
239
- }
240
- const existingTask = getTaskById(taskId);
241
- if (!existingTask) {
242
- return { success: false, message: `Task "${taskId}" not found.` };
243
- }
244
- if (existingTask.status !== "offered") {
245
- return { success: false, message: `Task "${taskId}" is not offered.` };
246
- }
247
- if (existingTask.offeredTo !== agentId) {
248
- return { success: false, message: `Task "${taskId}" was not offered to you.` };
249
- }
250
- // Check if task dependencies are met
251
- const { ready, blockedBy } = checkDependencies(taskId);
252
- if (!ready) {
253
- return {
254
- success: false,
255
- message: `Task "${taskId}" has unmet dependencies: ${blockedBy.join(", ")}. Cannot accept until dependencies are completed.`,
256
- };
257
- }
258
- // Budget admission gate (Phase 3). Same in-transaction placement
259
- // as the /api/poll gates so capacity AND budget share atomicity.
260
- // Phase 5: record dedup row + capture side-effect context for the
261
- // after-commit lead follow-up + workflow event-bus emit.
262
- const admission = canClaim(agentId, new Date());
263
- if (!admission.allowed) {
264
- const causeMsg =
265
- admission.cause === "agent"
266
- ? "agent daily budget exceeded"
267
- : "global daily budget exceeded";
268
- const utcDate = new Date().toISOString().slice(0, 10);
269
- const dedup = recordBudgetRefusalNotification({
270
- taskId,
271
- date: utcDate,
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
- return {
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
- refusalSideEffects: {
291
- context: {
292
- task: {
293
- id: existingTask.id,
294
- task: existingTask.task,
295
- slackChannelId: existingTask.slackChannelId,
296
- slackThreadTs: existingTask.slackThreadTs,
297
- slackUserId: existingTask.slackUserId,
298
- },
299
- agentId,
300
- date: utcDate,
301
- cause: admission.cause,
302
- agentSpendUsd: admission.agentSpend,
303
- agentBudgetUsd: admission.agentBudget,
304
- globalSpendUsd: admission.globalSpend,
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
- default:
399
- return { success: false, message: `Unknown action: ${action}` };
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
- const result = txn();
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
- // Phase 5: when the accept gate refused, run after-commit side
406
- // effects (lead follow-up + workflow bus). The dedup row was recorded
407
- // inside the txn; this just consumes the captured context.
408
- if (
409
- "refusalSideEffects" in result &&
410
- result.refusalSideEffects &&
411
- typeof result.refusalSideEffects === "object"
412
- ) {
413
- const sideEffects = result.refusalSideEffects as {
414
- context: BudgetRefusalContext;
415
- inserted: boolean;
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
- // Strip the internal-only `refusalSideEffects` field from the wire
421
- // response workers receive only the public refusal envelope.
422
- const { refusalSideEffects: _omit, ...publicResult } = result as {
423
- refusalSideEffects?: unknown;
424
- [key: string]: unknown;
425
- };
495
+ default:
496
+ return { success: false, message: `Unknown action: ${action}` };
497
+ }
498
+ });
426
499
 
427
- return {
428
- content: [{ type: "text", text: result.message }],
429
- structuredContent: {
430
- yourAgentId: agentId,
431
- ...publicResult,
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
  };