@desplega.ai/agent-swarm 1.69.0 → 1.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/openapi.json +62 -1
- package/package.json +1 -1
- package/src/agentmail/handlers.ts +87 -6
- package/src/be/db.ts +34 -2
- package/src/be/migrations/042_task_context_key.sql +13 -0
- package/src/commands/runner.ts +1 -0
- package/src/github/handlers.ts +42 -10
- package/src/gitlab/handlers.ts +29 -5
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +36 -26
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/http/schedules.ts +4 -2
- package/src/http/tasks.ts +4 -2
- package/src/linear/sync.ts +22 -10
- package/src/providers/claude-adapter.ts +51 -29
- package/src/scheduler/scheduler.ts +9 -10
- package/src/server.ts +2 -0
- package/src/slack/actions.ts +10 -9
- package/src/slack/assistant.ts +8 -4
- package/src/slack/handlers.ts +8 -3
- package/src/slack/thread-buffer.ts +61 -72
- package/src/tasks/additive-buffer.ts +152 -0
- package/src/tasks/additive-ingress.ts +125 -0
- package/src/tasks/context-key.ts +245 -0
- package/src/tasks/sibling-awareness.ts +144 -0
- package/src/tasks/sibling-block.ts +164 -0
- package/src/tests/additive-buffer.test.ts +186 -0
- package/src/tests/additive-ingress.test.ts +111 -0
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/context-key-db.test.ts +87 -0
- package/src/tests/context-key.test.ts +173 -0
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/sibling-awareness-db.test.ts +172 -0
- package/src/tests/sibling-block.test.ts +232 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +5 -2
- package/src/types.ts +5 -0
- package/src/workflows/executors/agent-task.ts +21 -14
package/src/hooks/hook.ts
CHANGED
|
@@ -355,8 +355,10 @@ export async function handleHook(): Promise<void> {
|
|
|
355
355
|
}
|
|
356
356
|
};
|
|
357
357
|
|
|
358
|
-
// Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption
|
|
359
|
-
|
|
358
|
+
// Minimum length for SOUL.md and IDENTITY.md to prevent accidental corruption.
|
|
359
|
+
// Raised from 100 to 500 after Picateclas profile corruption recurrences where
|
|
360
|
+
// a 234-char test sentinel payload was syncing into the real agent's DB row.
|
|
361
|
+
const IDENTITY_FILE_MIN_LENGTH = 500;
|
|
360
362
|
|
|
361
363
|
/**
|
|
362
364
|
* Sync SOUL.md and IDENTITY.md content back to the server
|
package/src/http/core.ts
CHANGED
|
@@ -16,7 +16,27 @@ import { startSlackApp, stopSlackApp } from "../slack";
|
|
|
16
16
|
import type { AgentStatus } from "../types";
|
|
17
17
|
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
18
18
|
import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
|
|
19
|
-
import {
|
|
19
|
+
import { routeRegistry } from "./route-def";
|
|
20
|
+
import { agentWithCapacity, getPathSegments, matchRoute, parseQueryParams } from "./utils";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check whether a request targets a route declared (via the `route()` factory)
|
|
24
|
+
* with `auth: { apiKey: false }` — i.e. one that opts out of the API-key
|
|
25
|
+
* bearer check. Handler files must use the `route()` factory for this to take
|
|
26
|
+
* effect; unknown paths fail closed (auth required).
|
|
27
|
+
*/
|
|
28
|
+
function isPublicRoute(method: string | undefined, pathSegments: string[]): boolean {
|
|
29
|
+
for (const def of routeRegistry) {
|
|
30
|
+
if (def.auth?.apiKey === false) {
|
|
31
|
+
if (
|
|
32
|
+
matchRoute(method, pathSegments, def.method.toUpperCase(), def.pattern, def.exact ?? true)
|
|
33
|
+
) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
20
40
|
|
|
21
41
|
/**
|
|
22
42
|
* Load global swarm_config entries into process.env.
|
|
@@ -121,31 +141,21 @@ export async function handleCore(
|
|
|
121
141
|
return true;
|
|
122
142
|
}
|
|
123
143
|
|
|
124
|
-
// API
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
req.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
!isTrackerAuth &&
|
|
140
|
-
!isWorkflowWebhook
|
|
141
|
-
) {
|
|
142
|
-
const authHeader = req.headers.authorization;
|
|
143
|
-
const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
144
|
-
|
|
145
|
-
if (providedKey !== apiKey) {
|
|
146
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
147
|
-
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
148
|
-
return true;
|
|
144
|
+
// API-key authentication (if API_KEY is configured). Routes that opt out via
|
|
145
|
+
// `route({ auth: { apiKey: false } })` — webhooks, OAuth provider callbacks,
|
|
146
|
+
// etc. — are skipped based on the central `routeRegistry`. Unknown paths
|
|
147
|
+
// fall through to the bearer check (fail-closed).
|
|
148
|
+
if (apiKey) {
|
|
149
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
150
|
+
if (!isPublicRoute(req.method, pathSegments)) {
|
|
151
|
+
const authHeader = req.headers.authorization;
|
|
152
|
+
const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
153
|
+
|
|
154
|
+
if (providedKey !== apiKey) {
|
|
155
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
156
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
149
159
|
}
|
|
150
160
|
}
|
|
151
161
|
|
package/src/http/mcp-oauth.ts
CHANGED
|
@@ -158,6 +158,27 @@ const authorizeRoute = route({
|
|
|
158
158
|
},
|
|
159
159
|
});
|
|
160
160
|
|
|
161
|
+
const authorizeUrlRoute = route({
|
|
162
|
+
method: "get",
|
|
163
|
+
path: "/api/mcp-oauth/{mcpServerId}/authorize-url",
|
|
164
|
+
pattern: ["api", "mcp-oauth", null, "authorize-url"],
|
|
165
|
+
summary:
|
|
166
|
+
"Build an OAuth authorize URL. Returns JSON so the browser can navigate without losing the Bearer auth header.",
|
|
167
|
+
tags: ["MCP OAuth"],
|
|
168
|
+
auth: { apiKey: true },
|
|
169
|
+
params: z.object({ mcpServerId: z.string() }),
|
|
170
|
+
query: z.object({
|
|
171
|
+
redirect: z.string().optional(),
|
|
172
|
+
userId: z.string().optional(),
|
|
173
|
+
scopes: z.string().optional(),
|
|
174
|
+
}),
|
|
175
|
+
responses: {
|
|
176
|
+
200: { description: "{ providerUrl: string }" },
|
|
177
|
+
400: { description: "MCP has no URL / does not require OAuth" },
|
|
178
|
+
404: { description: "MCP server not found" },
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
161
182
|
const callbackRoute = route({
|
|
162
183
|
method: "get",
|
|
163
184
|
path: "/api/mcp-oauth/callback",
|
|
@@ -236,6 +257,86 @@ const manualClientRoute = route({
|
|
|
236
257
|
},
|
|
237
258
|
});
|
|
238
259
|
|
|
260
|
+
// ─── Shared authorize flow ───────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
interface AuthorizeFlowQuery {
|
|
263
|
+
redirect?: string;
|
|
264
|
+
userId?: string;
|
|
265
|
+
scopes?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Discover metadata, DCR-register (or fail), build the authorize URL, and
|
|
270
|
+
* persist the pending session. Returns the provider `providerUrl` the caller
|
|
271
|
+
* should redirect to / respond with. On failure, writes a JSON error response
|
|
272
|
+
* and returns null.
|
|
273
|
+
*/
|
|
274
|
+
async function prepareAuthorizeFlow(
|
|
275
|
+
res: ServerResponse,
|
|
276
|
+
mcpServerId: string,
|
|
277
|
+
server: NonNullable<ReturnType<typeof getMcpServerById>>,
|
|
278
|
+
q: AuthorizeFlowQuery,
|
|
279
|
+
): Promise<string | null> {
|
|
280
|
+
const discovery = await discoverForMcp(server.url!);
|
|
281
|
+
if (!discovery) {
|
|
282
|
+
jsonError(res, "MCP server does not require OAuth", 400);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let clientId: string | null = null;
|
|
287
|
+
let clientSecret: string | null = null;
|
|
288
|
+
if (discovery.dcrSupported && discovery.registrationEndpoint) {
|
|
289
|
+
const dcr = await registerClient(discovery.registrationEndpoint, {
|
|
290
|
+
client_name: `agent-swarm (${server.name})`,
|
|
291
|
+
redirect_uris: [callbackRedirectUri()],
|
|
292
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
293
|
+
response_types: ["code"],
|
|
294
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
295
|
+
application_type: "web",
|
|
296
|
+
scope: (q.scopes ?? discovery.scopes.join(" ")) || undefined,
|
|
297
|
+
});
|
|
298
|
+
clientId = dcr.client_id;
|
|
299
|
+
clientSecret = dcr.client_secret ?? null;
|
|
300
|
+
} else {
|
|
301
|
+
jsonError(
|
|
302
|
+
res,
|
|
303
|
+
"DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
|
|
304
|
+
400,
|
|
305
|
+
);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const scopes = q.scopes ? q.scopes.split(" ").filter(Boolean) : discovery.scopes;
|
|
310
|
+
|
|
311
|
+
const built = await buildAuthorizeUrl({
|
|
312
|
+
authorizeUrl: discovery.authorizeUrl,
|
|
313
|
+
tokenUrl: discovery.tokenUrl,
|
|
314
|
+
clientId: clientId!,
|
|
315
|
+
redirectUri: callbackRedirectUri(),
|
|
316
|
+
scopes,
|
|
317
|
+
resource: discovery.resourceUrl,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
insertMcpOAuthPending({
|
|
321
|
+
state: built.state,
|
|
322
|
+
mcpServerId,
|
|
323
|
+
userId: q.userId ?? null,
|
|
324
|
+
codeVerifier: built.codeVerifier,
|
|
325
|
+
resourceUrl: discovery.resourceUrl,
|
|
326
|
+
authorizationServerIssuer: discovery.authorizationServerIssuer,
|
|
327
|
+
authorizeUrl: discovery.authorizeUrl,
|
|
328
|
+
tokenUrl: discovery.tokenUrl,
|
|
329
|
+
revocationUrl: discovery.revocationUrl,
|
|
330
|
+
scopes: scopes.join(" "),
|
|
331
|
+
dcrClientId: clientId!,
|
|
332
|
+
dcrClientSecret: clientSecret,
|
|
333
|
+
redirectUri: callbackRedirectUri(),
|
|
334
|
+
finalRedirect: q.redirect ?? null,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return built.url;
|
|
338
|
+
}
|
|
339
|
+
|
|
239
340
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
240
341
|
|
|
241
342
|
export async function handleMcpOAuth(
|
|
@@ -398,69 +499,40 @@ export async function handleMcpOAuth(
|
|
|
398
499
|
if (!server) return true;
|
|
399
500
|
|
|
400
501
|
try {
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
response_types: ["code"],
|
|
417
|
-
token_endpoint_auth_method: "client_secret_basic",
|
|
418
|
-
application_type: "web",
|
|
419
|
-
scope: (parsed.query.scopes ?? discovery.scopes.join(" ")) || undefined,
|
|
420
|
-
});
|
|
421
|
-
clientId = dcr.client_id;
|
|
422
|
-
clientSecret = dcr.client_secret ?? null;
|
|
423
|
-
} else {
|
|
424
|
-
jsonError(
|
|
425
|
-
res,
|
|
426
|
-
"DCR not supported — paste client_id/client_secret via POST /api/mcp-oauth/:id/manual-client first.",
|
|
427
|
-
400,
|
|
428
|
-
);
|
|
429
|
-
return true;
|
|
430
|
-
}
|
|
502
|
+
const providerUrl = await prepareAuthorizeFlow(
|
|
503
|
+
res,
|
|
504
|
+
parsed.params.mcpServerId,
|
|
505
|
+
server,
|
|
506
|
+
parsed.query,
|
|
507
|
+
);
|
|
508
|
+
if (!providerUrl) return true;
|
|
509
|
+
res.writeHead(302, { Location: providerUrl });
|
|
510
|
+
res.end();
|
|
511
|
+
} catch (err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
513
|
+
jsonError(res, `Authorize failed: ${message}`, 502);
|
|
514
|
+
}
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
431
517
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
authorizeUrl: discovery.authorizeUrl,
|
|
438
|
-
tokenUrl: discovery.tokenUrl,
|
|
439
|
-
clientId: clientId!,
|
|
440
|
-
redirectUri: callbackRedirectUri(),
|
|
441
|
-
scopes,
|
|
442
|
-
resource: discovery.resourceUrl,
|
|
443
|
-
});
|
|
518
|
+
// GET /api/mcp-oauth/:id/authorize-url — JSON variant of /authorize so the
|
|
519
|
+
// dashboard can fetch the provider URL with Bearer auth and then navigate.
|
|
520
|
+
if (authorizeUrlRoute.match(req.method, pathSegments)) {
|
|
521
|
+
const parsed = await authorizeUrlRoute.parse(req, res, pathSegments, queryParams);
|
|
522
|
+
if (!parsed) return true;
|
|
444
523
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
mcpServerId: parsed.params.mcpServerId,
|
|
448
|
-
userId: parsed.query.userId ?? null,
|
|
449
|
-
codeVerifier: built.codeVerifier,
|
|
450
|
-
resourceUrl: discovery.resourceUrl,
|
|
451
|
-
authorizationServerIssuer: discovery.authorizationServerIssuer,
|
|
452
|
-
authorizeUrl: discovery.authorizeUrl,
|
|
453
|
-
tokenUrl: discovery.tokenUrl,
|
|
454
|
-
revocationUrl: discovery.revocationUrl,
|
|
455
|
-
scopes: scopes.join(" "),
|
|
456
|
-
dcrClientId: clientId!,
|
|
457
|
-
dcrClientSecret: clientSecret,
|
|
458
|
-
redirectUri: callbackRedirectUri(),
|
|
459
|
-
finalRedirect: parsed.query.redirect ?? null,
|
|
460
|
-
});
|
|
524
|
+
const server = getMcpOrError(res, parsed.params.mcpServerId);
|
|
525
|
+
if (!server) return true;
|
|
461
526
|
|
|
462
|
-
|
|
463
|
-
|
|
527
|
+
try {
|
|
528
|
+
const providerUrl = await prepareAuthorizeFlow(
|
|
529
|
+
res,
|
|
530
|
+
parsed.params.mcpServerId,
|
|
531
|
+
server,
|
|
532
|
+
parsed.query,
|
|
533
|
+
);
|
|
534
|
+
if (!providerUrl) return true;
|
|
535
|
+
json(res, { providerUrl });
|
|
464
536
|
} catch (err) {
|
|
465
537
|
const message = err instanceof Error ? err.message : String(err);
|
|
466
538
|
jsonError(res, `Authorize failed: ${message}`, 502);
|
package/src/http/mcp-servers.ts
CHANGED
|
@@ -243,7 +243,11 @@ export async function handleMcpServers(
|
|
|
243
243
|
try {
|
|
244
244
|
const token = await ensureMcpToken(server.id);
|
|
245
245
|
if (token && token.status === "connected") {
|
|
246
|
-
|
|
246
|
+
// Normalize the bearer scheme to capital "Bearer": some resource
|
|
247
|
+
// servers reject the lowercase "bearer" RFC 6749 returns (issue #368).
|
|
248
|
+
// Non-bearer schemes (e.g. "MAC") are preserved verbatim.
|
|
249
|
+
const rawType = token.tokenType || "Bearer";
|
|
250
|
+
const prefix = rawType.toLowerCase() === "bearer" ? "Bearer" : rawType;
|
|
247
251
|
resolvedHeaders.Authorization = `${prefix} ${token.accessToken}`;
|
|
248
252
|
} else if (!token) {
|
|
249
253
|
authError = "No OAuth token for this MCP server";
|
package/src/http/schedules.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { CronExpressionParser } from "cron-parser";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import {
|
|
5
5
|
createScheduledTask,
|
|
6
|
-
createTaskExtended,
|
|
7
6
|
deleteScheduledTask,
|
|
8
7
|
getAgentById,
|
|
9
8
|
getDb,
|
|
@@ -12,6 +11,8 @@ import {
|
|
|
12
11
|
updateScheduledTask,
|
|
13
12
|
} from "../be/db";
|
|
14
13
|
import { calculateNextRun } from "../scheduler/scheduler";
|
|
14
|
+
import { scheduleContextKey } from "../tasks/context-key";
|
|
15
|
+
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
15
16
|
import { getExecutorRegistry } from "../workflows";
|
|
16
17
|
import { handleScheduleTrigger } from "../workflows/triggers";
|
|
17
18
|
import { route } from "./route-def";
|
|
@@ -285,7 +286,7 @@ export async function handleSchedules(
|
|
|
285
286
|
const now = new Date().toISOString();
|
|
286
287
|
|
|
287
288
|
const task = getDb().transaction(() => {
|
|
288
|
-
const createdTask =
|
|
289
|
+
const createdTask = createTaskWithSiblingAwareness(schedule.taskTemplate, {
|
|
289
290
|
creatorAgentId: schedule.createdByAgentId,
|
|
290
291
|
taskType: schedule.taskType,
|
|
291
292
|
tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
|
|
@@ -294,6 +295,7 @@ export async function handleSchedules(
|
|
|
294
295
|
model: schedule.model,
|
|
295
296
|
scheduleId: schedule.id,
|
|
296
297
|
source: "schedule",
|
|
298
|
+
contextKey: scheduleContextKey({ scheduleId: schedule.id }),
|
|
297
299
|
});
|
|
298
300
|
|
|
299
301
|
if (schedule.scheduleType === "one_time") {
|
package/src/http/tasks.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|
|
4
4
|
import {
|
|
5
5
|
cancelTask,
|
|
6
6
|
completeTask,
|
|
7
|
-
createTaskExtended,
|
|
8
7
|
failTask,
|
|
9
8
|
getAllTasks,
|
|
10
9
|
getDb,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
updateTaskProgress,
|
|
20
19
|
updateTaskVcs,
|
|
21
20
|
} from "../be/db";
|
|
21
|
+
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
22
22
|
import { telemetry } from "../telemetry";
|
|
23
23
|
import { route } from "./route-def";
|
|
24
24
|
import { json, jsonError } from "./utils";
|
|
@@ -63,6 +63,7 @@ const createTask = route({
|
|
|
63
63
|
parentTaskId: z.string().optional(),
|
|
64
64
|
source: z.string().optional(),
|
|
65
65
|
outputSchema: z.record(z.string(), z.unknown()).optional(),
|
|
66
|
+
contextKey: z.string().optional(),
|
|
66
67
|
}),
|
|
67
68
|
responses: {
|
|
68
69
|
201: { description: "Task created" },
|
|
@@ -240,7 +241,7 @@ export async function handleTasks(
|
|
|
240
241
|
if (!parsed) return true;
|
|
241
242
|
|
|
242
243
|
try {
|
|
243
|
-
const task =
|
|
244
|
+
const task = createTaskWithSiblingAwareness(parsed.body.task, {
|
|
244
245
|
agentId: parsed.body.agentId || undefined,
|
|
245
246
|
creatorAgentId: myAgentId || undefined,
|
|
246
247
|
taskType: parsed.body.taskType || undefined,
|
|
@@ -252,6 +253,7 @@ export async function handleTasks(
|
|
|
252
253
|
parentTaskId: parsed.body.parentTaskId || undefined,
|
|
253
254
|
source: (parsed.body.source as import("../types").AgentTaskSource) || "api",
|
|
254
255
|
outputSchema: parsed.body.outputSchema || undefined,
|
|
256
|
+
contextKey: parsed.body.contextKey || undefined,
|
|
255
257
|
});
|
|
256
258
|
|
|
257
259
|
ensure({
|
package/src/linear/sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cancelTask,
|
|
1
|
+
import { cancelTask, getAllAgents, getTaskById, resolveUser } from "../be/db";
|
|
2
2
|
import { getOAuthTokens } from "../be/db-queries/oauth";
|
|
3
3
|
import {
|
|
4
4
|
createTrackerSync,
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from "../be/db-queries/tracker";
|
|
9
9
|
import { ensureToken } from "../oauth/ensure-token";
|
|
10
10
|
import { resolveTemplate } from "../prompts/resolver";
|
|
11
|
+
import { linearContextKey } from "../tasks/context-key";
|
|
12
|
+
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
11
13
|
// Side-effect import: registers all Linear event templates in the in-memory registry
|
|
12
14
|
import "./templates";
|
|
13
15
|
|
|
@@ -287,18 +289,26 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
|
|
|
287
289
|
if (existing) {
|
|
288
290
|
const existingTask = getTaskById(existing.swarmId);
|
|
289
291
|
|
|
290
|
-
// If the task is still active,
|
|
292
|
+
// If the task is still active, post a user-visible response on the new
|
|
293
|
+
// session explaining that a sibling is already in flight and the new
|
|
294
|
+
// session can be closed. Do NOT create a duplicate swarm task. If the user
|
|
295
|
+
// wants to force a fresh run, they can re-assign the issue after the
|
|
296
|
+
// current task finishes.
|
|
291
297
|
if (existingTask && !["completed", "failed", "cancelled"].includes(existingTask.status)) {
|
|
292
298
|
console.log(
|
|
293
|
-
`[Linear Sync] Issue ${issueIdentifier} already tracked as active task ${existing.swarmId}, skipping`,
|
|
299
|
+
`[Linear Sync] Issue ${issueIdentifier} already tracked as active task ${existing.swarmId} (status: ${existingTask.status}), skipping duplicate`,
|
|
294
300
|
);
|
|
295
301
|
if (sessionId) {
|
|
296
302
|
taskSessionMap.set(existingTask.id, sessionId);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
303
|
+
const refuseMsg = [
|
|
304
|
+
`This issue is already being worked on — task \`${existing.swarmId}\` is currently \`${existingTask.status}\`.`,
|
|
305
|
+
"",
|
|
306
|
+
"To avoid duplicating work, I'm not starting a new session for this re-assignment. Progress on the active task will continue to be posted here.",
|
|
307
|
+
"",
|
|
308
|
+
"If you want to force a fresh run, wait for the current task to finish (or cancel it) and re-assign the issue.",
|
|
309
|
+
].join("\n");
|
|
310
|
+
postAgentSessionResponse(sessionId, refuseMsg).catch((err) => {
|
|
311
|
+
console.error("[Linear Sync] Failed to post hard-refuse response:", err);
|
|
302
312
|
});
|
|
303
313
|
}
|
|
304
314
|
return;
|
|
@@ -327,11 +337,12 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
|
|
|
327
337
|
return;
|
|
328
338
|
}
|
|
329
339
|
|
|
330
|
-
const task =
|
|
340
|
+
const task = createTaskWithSiblingAwareness(templateResult.text, {
|
|
331
341
|
agentId: lead?.id ?? "",
|
|
332
342
|
source: "linear",
|
|
333
343
|
taskType: "linear-issue",
|
|
334
344
|
requestedByUserId,
|
|
345
|
+
contextKey: linearContextKey({ issueIdentifier }),
|
|
335
346
|
});
|
|
336
347
|
|
|
337
348
|
// Delete old tracker_sync before creating new one (UNIQUE constraint)
|
|
@@ -570,11 +581,12 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
|
|
|
570
581
|
return;
|
|
571
582
|
}
|
|
572
583
|
|
|
573
|
-
const task =
|
|
584
|
+
const task = createTaskWithSiblingAwareness(followupResult.text, {
|
|
574
585
|
agentId: lead?.id ?? "",
|
|
575
586
|
source: "linear",
|
|
576
587
|
taskType: "linear-issue",
|
|
577
588
|
requestedByUserId: promptedRequestedByUserId,
|
|
589
|
+
contextKey: linearContextKey({ issueIdentifier }),
|
|
578
590
|
});
|
|
579
591
|
|
|
580
592
|
// Repoint the existing tracker_sync to the new follow-up task (can't create a
|
|
@@ -106,6 +106,52 @@ async function fetchInstalledMcpServers(
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Merge a base MCP config (typically read from `.mcp.json`) with freshly-resolved
|
|
111
|
+
* installed servers from the API, and inject the per-task `X-Source-Task-Id` header
|
|
112
|
+
* into the `agent-swarm` entry.
|
|
113
|
+
*
|
|
114
|
+
* Precedence: installed servers from the API WIN over entries already in `.mcp.json`.
|
|
115
|
+
* This guards against stale credentials from a `.mcp.json` that was written once at
|
|
116
|
+
* container startup and never refreshed (see issue #369). The per-session fetch
|
|
117
|
+
* carries current OAuth tokens / rotated secrets / up-to-date installs.
|
|
118
|
+
*
|
|
119
|
+
* Exported for unit testing.
|
|
120
|
+
*/
|
|
121
|
+
export function mergeMcpConfig(
|
|
122
|
+
baseConfig: { mcpServers?: Record<string, unknown> } | null,
|
|
123
|
+
installedServers: Record<string, Record<string, unknown>> | null,
|
|
124
|
+
taskId: string,
|
|
125
|
+
): { mcpServers: Record<string, unknown> } {
|
|
126
|
+
const config: { mcpServers: Record<string, unknown> } = {
|
|
127
|
+
mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Installed servers from the API always win — fresh credentials replace stale ones.
|
|
131
|
+
if (installedServers) {
|
|
132
|
+
for (const [name, serverConfig] of Object.entries(installedServers)) {
|
|
133
|
+
config.mcpServers[name] = serverConfig;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find the agent-swarm server entry (could be named "agent-swarm" or similar)
|
|
138
|
+
const serverKey = Object.keys(config.mcpServers).find(
|
|
139
|
+
(k) =>
|
|
140
|
+
k === "agent-swarm" ||
|
|
141
|
+
((config.mcpServers[k] as Record<string, unknown>)?.headers &&
|
|
142
|
+
((config.mcpServers[k] as Record<string, Record<string, unknown>>).headers?.[
|
|
143
|
+
"X-Agent-ID"
|
|
144
|
+
] as unknown)),
|
|
145
|
+
);
|
|
146
|
+
if (serverKey) {
|
|
147
|
+
const server = config.mcpServers[serverKey] as Record<string, unknown>;
|
|
148
|
+
if (!server.headers) server.headers = {};
|
|
149
|
+
(server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return config;
|
|
153
|
+
}
|
|
154
|
+
|
|
109
155
|
/**
|
|
110
156
|
* Create a per-session MCP config file with X-Source-Task-Id header injected
|
|
111
157
|
* and installed MCP servers merged in.
|
|
@@ -138,39 +184,14 @@ async function createSessionMcpConfig(
|
|
|
138
184
|
if (!mcpJsonPath && !installedServers) return null;
|
|
139
185
|
|
|
140
186
|
try {
|
|
141
|
-
let
|
|
187
|
+
let baseConfig: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
|
|
142
188
|
if (mcpJsonPath) {
|
|
143
189
|
const file = Bun.file(mcpJsonPath);
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
const servers = config?.mcpServers;
|
|
147
|
-
if (!servers && !installedServers) return null;
|
|
148
|
-
|
|
149
|
-
if (!config.mcpServers) config.mcpServers = {};
|
|
150
|
-
|
|
151
|
-
// Find the agent-swarm server entry (could be named "agent-swarm" or similar)
|
|
152
|
-
const serverKey = Object.keys(config.mcpServers).find(
|
|
153
|
-
(k) =>
|
|
154
|
-
k === "agent-swarm" ||
|
|
155
|
-
((config.mcpServers![k] as Record<string, unknown>)?.headers &&
|
|
156
|
-
((config.mcpServers![k] as Record<string, Record<string, unknown>>).headers?.[
|
|
157
|
-
"X-Agent-ID"
|
|
158
|
-
] as unknown)),
|
|
159
|
-
);
|
|
160
|
-
if (serverKey) {
|
|
161
|
-
const server = config.mcpServers[serverKey] as Record<string, unknown>;
|
|
162
|
-
if (!server.headers) server.headers = {};
|
|
163
|
-
(server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
|
|
190
|
+
baseConfig = await file.json();
|
|
164
191
|
}
|
|
192
|
+
if (!baseConfig?.mcpServers && !installedServers) return null;
|
|
165
193
|
|
|
166
|
-
|
|
167
|
-
if (installedServers) {
|
|
168
|
-
for (const [name, serverConfig] of Object.entries(installedServers)) {
|
|
169
|
-
if (!config.mcpServers[name]) {
|
|
170
|
-
config.mcpServers[name] = serverConfig;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
194
|
+
const config = mergeMcpConfig(baseConfig, installedServers ?? null, taskId);
|
|
174
195
|
|
|
175
196
|
// Write per-session config to /tmp — no race, each session has its own file
|
|
176
197
|
const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
|
|
@@ -212,6 +233,7 @@ class ClaudeSession implements ProviderSession {
|
|
|
212
233
|
this.proc = Bun.spawn(cmd, {
|
|
213
234
|
cwd: this.config.cwd,
|
|
214
235
|
env: {
|
|
236
|
+
ENABLE_PROMPT_CACHING_1H: "1",
|
|
215
237
|
...(config.env || process.env),
|
|
216
238
|
TASK_FILE: taskFilePath,
|
|
217
239
|
} as Record<string, string>,
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { ensure } from "@desplega.ai/business-use";
|
|
2
2
|
import { CronExpressionParser } from "cron-parser";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
getDueScheduledTasks,
|
|
7
|
-
getScheduledTaskById,
|
|
8
|
-
updateScheduledTask,
|
|
9
|
-
} from "@/be/db";
|
|
3
|
+
import { getDb, getDueScheduledTasks, getScheduledTaskById, updateScheduledTask } from "@/be/db";
|
|
4
|
+
import { scheduleContextKey } from "@/tasks/context-key";
|
|
5
|
+
import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
|
|
10
6
|
import type { ScheduledTask } from "@/types";
|
|
11
7
|
import type { ExecutorRegistry } from "@/workflows/executors/registry";
|
|
12
8
|
import { handleScheduleTrigger } from "@/workflows/triggers";
|
|
@@ -49,7 +45,7 @@ async function recoverMissedSchedules(): Promise<void> {
|
|
|
49
45
|
|
|
50
46
|
if (!triggeredWorkflows) {
|
|
51
47
|
const tx = getDb().transaction(() => {
|
|
52
|
-
|
|
48
|
+
createTaskWithSiblingAwareness(schedule.taskTemplate, {
|
|
53
49
|
creatorAgentId: schedule.createdByAgentId,
|
|
54
50
|
taskType: schedule.taskType,
|
|
55
51
|
tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "recovered"],
|
|
@@ -58,6 +54,7 @@ async function recoverMissedSchedules(): Promise<void> {
|
|
|
58
54
|
model: schedule.model,
|
|
59
55
|
scheduleId: schedule.id,
|
|
60
56
|
source: "schedule",
|
|
57
|
+
contextKey: scheduleContextKey({ scheduleId: schedule.id }),
|
|
61
58
|
});
|
|
62
59
|
});
|
|
63
60
|
tx();
|
|
@@ -153,7 +150,7 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
|
|
|
153
150
|
if (!triggeredWorkflows) {
|
|
154
151
|
// No workflows linked — create standalone task (existing behavior)
|
|
155
152
|
getDb().transaction(() => {
|
|
156
|
-
|
|
153
|
+
createTaskWithSiblingAwareness(schedule.taskTemplate, {
|
|
157
154
|
creatorAgentId: schedule.createdByAgentId,
|
|
158
155
|
taskType: schedule.taskType,
|
|
159
156
|
tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`],
|
|
@@ -162,6 +159,7 @@ async function executeSchedule(schedule: ScheduledTask): Promise<void> {
|
|
|
162
159
|
model: schedule.model,
|
|
163
160
|
scheduleId: schedule.id,
|
|
164
161
|
source: "schedule",
|
|
162
|
+
contextKey: scheduleContextKey({ scheduleId: schedule.id }),
|
|
165
163
|
});
|
|
166
164
|
})();
|
|
167
165
|
}
|
|
@@ -343,7 +341,7 @@ export async function runScheduleNow(scheduleId: string): Promise<void> {
|
|
|
343
341
|
if (!triggeredWorkflows) {
|
|
344
342
|
// No workflows linked — create standalone task (existing behavior)
|
|
345
343
|
getDb().transaction(() => {
|
|
346
|
-
|
|
344
|
+
createTaskWithSiblingAwareness(schedule.taskTemplate, {
|
|
347
345
|
creatorAgentId: schedule.createdByAgentId,
|
|
348
346
|
taskType: schedule.taskType,
|
|
349
347
|
tags: [...schedule.tags, "scheduled", `schedule:${schedule.name}`, "manual-run"],
|
|
@@ -352,6 +350,7 @@ export async function runScheduleNow(scheduleId: string): Promise<void> {
|
|
|
352
350
|
model: schedule.model,
|
|
353
351
|
scheduleId: schedule.id,
|
|
354
352
|
source: "schedule",
|
|
353
|
+
contextKey: scheduleContextKey({ scheduleId: schedule.id }),
|
|
355
354
|
});
|
|
356
355
|
})();
|
|
357
356
|
}
|
package/src/server.ts
CHANGED
|
@@ -77,6 +77,7 @@ import { registerSlackListChannelsTool } from "./tools/slack-list-channels";
|
|
|
77
77
|
import { registerSlackPostTool } from "./tools/slack-post";
|
|
78
78
|
import { registerSlackReadTool } from "./tools/slack-read";
|
|
79
79
|
import { registerSlackReplyTool } from "./tools/slack-reply";
|
|
80
|
+
import { registerSlackStartThreadTool } from "./tools/slack-start-thread";
|
|
80
81
|
import { registerSlackUploadFileTool } from "./tools/slack-upload-file";
|
|
81
82
|
import { registerStoreProgressTool } from "./tools/store-progress";
|
|
82
83
|
// Swarm config tools
|
|
@@ -189,6 +190,7 @@ export function createServer() {
|
|
|
189
190
|
registerSlackReplyTool(server);
|
|
190
191
|
registerSlackReadTool(server);
|
|
191
192
|
registerSlackPostTool(server);
|
|
193
|
+
registerSlackStartThreadTool(server);
|
|
192
194
|
registerSlackListChannelsTool(server);
|
|
193
195
|
registerSlackUploadFileTool(server);
|
|
194
196
|
registerSlackDownloadFileTool(server);
|