@desplega.ai/agent-swarm 1.69.1 → 1.71.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.
Files changed (54) hide show
  1. package/openapi.json +242 -1
  2. package/package.json +1 -1
  3. package/src/be/db-queries/oauth.ts +45 -15
  4. package/src/be/db-queries/tracker.ts +109 -0
  5. package/src/be/migrations/043_jira_source.sql +128 -0
  6. package/src/commands/runner.ts +8 -2
  7. package/src/hooks/hook.ts +4 -2
  8. package/src/http/core.ts +21 -26
  9. package/src/http/index.ts +9 -1
  10. package/src/http/mcp-oauth.ts +132 -60
  11. package/src/http/mcp-servers.ts +5 -1
  12. package/src/http/route-def.ts +19 -0
  13. package/src/http/trackers/index.ts +13 -0
  14. package/src/http/trackers/jira.ts +331 -0
  15. package/src/jira/adf.ts +132 -0
  16. package/src/jira/app.ts +65 -0
  17. package/src/jira/client.ts +82 -0
  18. package/src/jira/index.ts +24 -0
  19. package/src/jira/metadata.ts +104 -0
  20. package/src/jira/oauth.ts +98 -0
  21. package/src/jira/outbound.ts +155 -0
  22. package/src/jira/sync.ts +534 -0
  23. package/src/jira/templates.ts +84 -0
  24. package/src/jira/types.ts +35 -0
  25. package/src/jira/webhook-lifecycle.ts +363 -0
  26. package/src/jira/webhook.ts +159 -0
  27. package/src/oauth/wrapper.ts +11 -1
  28. package/src/providers/claude-adapter.ts +50 -29
  29. package/src/server.ts +2 -0
  30. package/src/tasks/context-key.ts +29 -1
  31. package/src/telemetry.ts +38 -3
  32. package/src/tests/claude-adapter.test.ts +143 -1
  33. package/src/tests/context-key.test.ts +19 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/jira-adf.test.ts +239 -0
  36. package/src/tests/jira-metadata.test.ts +147 -0
  37. package/src/tests/jira-oauth.test.ts +167 -0
  38. package/src/tests/jira-outbound-sync.test.ts +334 -0
  39. package/src/tests/jira-sync.test.ts +327 -0
  40. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  41. package/src/tests/jira-webhook.test.ts +274 -0
  42. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  43. package/src/tests/telemetry-init.test.ts +108 -0
  44. package/src/tests/tool-annotations.test.ts +1 -0
  45. package/src/tools/slack-post.ts +10 -3
  46. package/src/tools/slack-start-thread.ts +123 -0
  47. package/src/tools/tool-config.ts +2 -1
  48. package/src/tools/tracker/tracker-link-task.ts +1 -1
  49. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  50. package/src/tools/tracker/tracker-status.ts +1 -1
  51. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  52. package/src/tools/update-profile.ts +5 -2
  53. package/src/tracker/types.ts +1 -1
  54. package/src/types.ts +1 -0
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.69.1",
5
+ "version": "1.71.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -4855,6 +4855,64 @@
4855
4855
  }
4856
4856
  }
4857
4857
  },
4858
+ "/api/mcp-oauth/{mcpServerId}/authorize-url": {
4859
+ "get": {
4860
+ "summary": "Build an OAuth authorize URL. Returns JSON so the browser can navigate without losing the Bearer auth header.",
4861
+ "tags": [
4862
+ "MCP OAuth"
4863
+ ],
4864
+ "security": [
4865
+ {
4866
+ "bearerAuth": []
4867
+ }
4868
+ ],
4869
+ "parameters": [
4870
+ {
4871
+ "schema": {
4872
+ "type": "string"
4873
+ },
4874
+ "required": true,
4875
+ "name": "mcpServerId",
4876
+ "in": "path"
4877
+ },
4878
+ {
4879
+ "schema": {
4880
+ "type": "string"
4881
+ },
4882
+ "required": false,
4883
+ "name": "redirect",
4884
+ "in": "query"
4885
+ },
4886
+ {
4887
+ "schema": {
4888
+ "type": "string"
4889
+ },
4890
+ "required": false,
4891
+ "name": "userId",
4892
+ "in": "query"
4893
+ },
4894
+ {
4895
+ "schema": {
4896
+ "type": "string"
4897
+ },
4898
+ "required": false,
4899
+ "name": "scopes",
4900
+ "in": "query"
4901
+ }
4902
+ ],
4903
+ "responses": {
4904
+ "200": {
4905
+ "description": "{ providerUrl: string }"
4906
+ },
4907
+ "400": {
4908
+ "description": "MCP has no URL / does not require OAuth"
4909
+ },
4910
+ "404": {
4911
+ "description": "MCP server not found"
4912
+ }
4913
+ }
4914
+ }
4915
+ },
4858
4916
  "/api/mcp-oauth/callback": {
4859
4917
  "get": {
4860
4918
  "summary": "OAuth redirect target. Exchanges code -> tokens and redirects back to dashboard.",
@@ -6151,6 +6209,189 @@
6151
6209
  }
6152
6210
  }
6153
6211
  },
6212
+ "/api/trackers/jira/authorize": {
6213
+ "get": {
6214
+ "summary": "Redirect to Atlassian OAuth consent screen",
6215
+ "tags": [
6216
+ "Trackers"
6217
+ ],
6218
+ "responses": {
6219
+ "302": {
6220
+ "description": "Redirect to Atlassian OAuth"
6221
+ },
6222
+ "500": {
6223
+ "description": "Failed to generate authorization URL"
6224
+ },
6225
+ "503": {
6226
+ "description": "Jira integration not configured"
6227
+ }
6228
+ }
6229
+ }
6230
+ },
6231
+ "/api/trackers/jira/callback": {
6232
+ "get": {
6233
+ "summary": "Handle Jira OAuth callback (resolves cloudId via accessible-resources)",
6234
+ "tags": [
6235
+ "Trackers"
6236
+ ],
6237
+ "parameters": [
6238
+ {
6239
+ "schema": {
6240
+ "type": "string"
6241
+ },
6242
+ "required": true,
6243
+ "name": "code",
6244
+ "in": "query"
6245
+ },
6246
+ {
6247
+ "schema": {
6248
+ "type": "string"
6249
+ },
6250
+ "required": true,
6251
+ "name": "state",
6252
+ "in": "query"
6253
+ }
6254
+ ],
6255
+ "responses": {
6256
+ "200": {
6257
+ "description": "OAuth complete"
6258
+ },
6259
+ "400": {
6260
+ "description": "Invalid state or code"
6261
+ },
6262
+ "500": {
6263
+ "description": "Token exchange or accessible-resources fetch failed"
6264
+ }
6265
+ }
6266
+ }
6267
+ },
6268
+ "/api/trackers/jira/status": {
6269
+ "get": {
6270
+ "summary": "Jira connection status, cloudId/siteUrl, token expiry, expected webhook URL, scope/token-config flags",
6271
+ "tags": [
6272
+ "Trackers"
6273
+ ],
6274
+ "security": [
6275
+ {
6276
+ "bearerAuth": []
6277
+ }
6278
+ ],
6279
+ "responses": {
6280
+ "200": {
6281
+ "description": "Connection status"
6282
+ },
6283
+ "503": {
6284
+ "description": "Jira integration not configured"
6285
+ }
6286
+ }
6287
+ }
6288
+ },
6289
+ "/api/trackers/jira/webhook/{token}": {
6290
+ "post": {
6291
+ "summary": "Receive Jira webhook events (URL-token authenticated). Phase 2 stub — Phase 3 fills in dispatch.",
6292
+ "tags": [
6293
+ "Trackers"
6294
+ ],
6295
+ "parameters": [
6296
+ {
6297
+ "schema": {
6298
+ "type": "string"
6299
+ },
6300
+ "required": true,
6301
+ "name": "token",
6302
+ "in": "path"
6303
+ }
6304
+ ],
6305
+ "responses": {
6306
+ "200": {
6307
+ "description": "Event accepted"
6308
+ },
6309
+ "401": {
6310
+ "description": "Invalid URL token"
6311
+ },
6312
+ "503": {
6313
+ "description": "Jira webhook handler not configured"
6314
+ }
6315
+ }
6316
+ }
6317
+ },
6318
+ "/api/trackers/jira/webhook-register": {
6319
+ "post": {
6320
+ "summary": "Register a Jira dynamic webhook (admin only)",
6321
+ "tags": [
6322
+ "Trackers"
6323
+ ],
6324
+ "security": [
6325
+ {
6326
+ "bearerAuth": []
6327
+ }
6328
+ ],
6329
+ "requestBody": {
6330
+ "content": {
6331
+ "application/json": {
6332
+ "schema": {
6333
+ "type": "object",
6334
+ "properties": {
6335
+ "jqlFilter": {
6336
+ "type": "string",
6337
+ "minLength": 1
6338
+ }
6339
+ },
6340
+ "required": [
6341
+ "jqlFilter"
6342
+ ]
6343
+ }
6344
+ }
6345
+ }
6346
+ },
6347
+ "responses": {
6348
+ "200": {
6349
+ "description": "Webhook registered"
6350
+ },
6351
+ "400": {
6352
+ "description": "Invalid jqlFilter"
6353
+ },
6354
+ "503": {
6355
+ "description": "Jira not connected or JIRA_WEBHOOK_TOKEN missing"
6356
+ }
6357
+ }
6358
+ }
6359
+ },
6360
+ "/api/trackers/jira/webhook/{id}": {
6361
+ "delete": {
6362
+ "summary": "Delete a Jira dynamic webhook (admin only)",
6363
+ "tags": [
6364
+ "Trackers"
6365
+ ],
6366
+ "security": [
6367
+ {
6368
+ "bearerAuth": []
6369
+ }
6370
+ ],
6371
+ "parameters": [
6372
+ {
6373
+ "schema": {
6374
+ "type": "integer",
6375
+ "exclusiveMinimum": 0
6376
+ },
6377
+ "required": true,
6378
+ "name": "id",
6379
+ "in": "path"
6380
+ }
6381
+ ],
6382
+ "responses": {
6383
+ "200": {
6384
+ "description": "Webhook deleted"
6385
+ },
6386
+ "400": {
6387
+ "description": "Invalid webhook id"
6388
+ },
6389
+ "503": {
6390
+ "description": "Jira not connected"
6391
+ }
6392
+ }
6393
+ }
6394
+ },
6154
6395
  "/api/trackers/linear/authorize": {
6155
6396
  "get": {
6156
6397
  "summary": "Redirect to Linear OAuth consent screen",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.69.1",
3
+ "version": "1.71.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -39,9 +39,14 @@ export function upsertOAuthApp(
39
39
  metadata?: string;
40
40
  },
41
41
  ): void {
42
- getDb()
43
- .query(
44
- `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
42
+ // metadata is treated as a runtime-owned column (cloudId, webhookIds, etc.
43
+ // are written by OAuth callback + webhook-register flows). On INSERT we
44
+ // seed it with whatever the caller passed (or "{}"); on UPDATE we ONLY
45
+ // overwrite when the caller explicitly provided one — otherwise the
46
+ // existing value is preserved across server restarts.
47
+ const metadataProvided = data.metadata !== undefined;
48
+ const sql = metadataProvided
49
+ ? `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
45
50
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
46
51
  ON CONFLICT(provider) DO UPDATE SET
47
52
  clientId = excluded.clientId,
@@ -51,18 +56,43 @@ export function upsertOAuthApp(
51
56
  redirectUri = excluded.redirectUri,
52
57
  scopes = excluded.scopes,
53
58
  metadata = excluded.metadata,
54
- updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
55
- )
56
- .run(
57
- provider,
58
- data.clientId,
59
- data.clientSecret,
60
- data.authorizeUrl,
61
- data.tokenUrl,
62
- data.redirectUri,
63
- data.scopes,
64
- data.metadata ?? "{}",
65
- );
59
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
60
+ : `INSERT INTO oauth_apps (provider, clientId, clientSecret, authorizeUrl, tokenUrl, redirectUri, scopes, metadata)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?, '{}')
62
+ ON CONFLICT(provider) DO UPDATE SET
63
+ clientId = excluded.clientId,
64
+ clientSecret = excluded.clientSecret,
65
+ authorizeUrl = excluded.authorizeUrl,
66
+ tokenUrl = excluded.tokenUrl,
67
+ redirectUri = excluded.redirectUri,
68
+ scopes = excluded.scopes,
69
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`;
70
+ if (metadataProvided) {
71
+ getDb()
72
+ .query(sql)
73
+ .run(
74
+ provider,
75
+ data.clientId,
76
+ data.clientSecret,
77
+ data.authorizeUrl,
78
+ data.tokenUrl,
79
+ data.redirectUri,
80
+ data.scopes,
81
+ data.metadata as string,
82
+ );
83
+ } else {
84
+ getDb()
85
+ .query(sql)
86
+ .run(
87
+ provider,
88
+ data.clientId,
89
+ data.clientSecret,
90
+ data.authorizeUrl,
91
+ data.tokenUrl,
92
+ data.redirectUri,
93
+ data.scopes,
94
+ );
95
+ }
66
96
  }
67
97
 
68
98
  // ── OAuth Tokens ──
@@ -41,6 +41,75 @@ export function getTrackerSyncByExternalId(
41
41
  return row ? normalizeTrackerSync(row) : null;
42
42
  }
43
43
 
44
+ /**
45
+ * Idempotent UNIQUE-gated insert into `tracker_sync`. Returns
46
+ * `{ inserted: true, sync }` when a fresh row was created, or
47
+ * `{ inserted: false, sync }` when the `(provider, entityType, externalId)`
48
+ * tuple already had a row. Used by inbound webhook handlers (currently Jira)
49
+ * to gate task creation atomically: insert sync row first, only call
50
+ * `createTaskExtended` if `inserted === true`.
51
+ *
52
+ * Note: this is a "claim" insert — `swarmId` is initially the sentinel value
53
+ * passed in (callers typically pass a placeholder like `""` or a known UUID),
54
+ * then update it with `updateTrackerSyncSwarmId` once the task is created.
55
+ */
56
+ export function createTrackerSyncIfAbsent(data: {
57
+ provider: string;
58
+ entityType: "task";
59
+ providerEntityType?: string | null;
60
+ swarmId: string;
61
+ externalId: string;
62
+ externalIdentifier?: string | null;
63
+ externalUrl?: string | null;
64
+ lastSyncOrigin?: "swarm" | "external" | null;
65
+ lastDeliveryId?: string | null;
66
+ syncDirection?: "inbound" | "outbound" | "bidirectional";
67
+ }): { inserted: boolean; sync: TrackerSync } {
68
+ const insertResult = getDb()
69
+ .query(
70
+ `INSERT INTO tracker_sync (provider, entityType, providerEntityType, swarmId, externalId, externalIdentifier, externalUrl, lastSyncOrigin, lastDeliveryId, syncDirection)
71
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
72
+ ON CONFLICT (provider, entityType, externalId) DO NOTHING
73
+ RETURNING *`,
74
+ )
75
+ .get(
76
+ data.provider,
77
+ data.entityType,
78
+ data.providerEntityType ?? null,
79
+ data.swarmId,
80
+ data.externalId,
81
+ data.externalIdentifier ?? null,
82
+ data.externalUrl ?? null,
83
+ data.lastSyncOrigin ?? null,
84
+ data.lastDeliveryId ?? null,
85
+ data.syncDirection ?? "inbound",
86
+ ) as TrackerSync | null;
87
+
88
+ if (insertResult) {
89
+ return { inserted: true, sync: normalizeTrackerSync(insertResult) };
90
+ }
91
+
92
+ // Row already existed — fetch it for the caller.
93
+ const existing = getTrackerSyncByExternalId(data.provider, data.entityType, data.externalId);
94
+ if (!existing) {
95
+ // Should be unreachable: ON CONFLICT means a row exists, but this guard
96
+ // satisfies the type system and surfaces unexpected races loudly.
97
+ throw new Error(
98
+ `[tracker] createTrackerSyncIfAbsent: ON CONFLICT fired but no existing row found for (${data.provider}, ${data.entityType}, ${data.externalId})`,
99
+ );
100
+ }
101
+ return { inserted: false, sync: existing };
102
+ }
103
+
104
+ /**
105
+ * Update the `swarmId` on an existing `tracker_sync` row. Used after the
106
+ * idempotent `createTrackerSyncIfAbsent` returned `{ inserted: true }` and
107
+ * we've now created the swarm task that should own this row.
108
+ */
109
+ export function updateTrackerSyncSwarmId(id: string, swarmId: string): void {
110
+ getDb().query("UPDATE tracker_sync SET swarmId = ? WHERE id = ?").run(swarmId, id);
111
+ }
112
+
44
113
  export function createTrackerSync(data: {
45
114
  provider: string;
46
115
  entityType: "task";
@@ -128,6 +197,46 @@ export function deleteTrackerSync(id: string): void {
128
197
  getDb().query("DELETE FROM tracker_sync WHERE id = ?").run(id);
129
198
  }
130
199
 
200
+ /**
201
+ * Check whether a `tracker_sync` row exists for `provider` with the given
202
+ * `lastDeliveryId`. Used by inbound webhook handlers (Jira) to dedupe
203
+ * deliveries via DB-persisted state instead of a process-local Map.
204
+ *
205
+ * Returns `false` when `deliveryId` is falsy/empty so callers don't have to
206
+ * branch.
207
+ */
208
+ export function hasTrackerDelivery(
209
+ provider: string,
210
+ deliveryId: string | null | undefined,
211
+ ): boolean {
212
+ if (!deliveryId) return false;
213
+ const row = getDb()
214
+ .query("SELECT 1 AS hit FROM tracker_sync WHERE provider = ? AND lastDeliveryId = ? LIMIT 1")
215
+ .get(provider, deliveryId) as { hit: number } | null;
216
+ return !!row;
217
+ }
218
+
219
+ /**
220
+ * Mark a delivery as processed by writing `deliveryId` into the relevant
221
+ * `tracker_sync` row identified by `(provider, entityType, externalId)`.
222
+ *
223
+ * No-op when the row doesn't exist yet (the very first inbound event creates
224
+ * the row via `createTrackerSyncIfAbsent` — recording delivery happens after
225
+ * that). Caller is responsible for ordering.
226
+ */
227
+ export function markTrackerDelivery(
228
+ provider: string,
229
+ entityType: "task",
230
+ externalId: string,
231
+ deliveryId: string,
232
+ ): void {
233
+ getDb()
234
+ .query(
235
+ "UPDATE tracker_sync SET lastDeliveryId = ? WHERE provider = ? AND entityType = ? AND externalId = ?",
236
+ )
237
+ .run(deliveryId, provider, entityType, externalId);
238
+ }
239
+
131
240
  export function getAllTrackerSyncs(provider?: string, entityType?: "task"): TrackerSync[] {
132
241
  const conditions: string[] = [];
133
242
  const values: string[] = [];
@@ -0,0 +1,128 @@
1
+ -- Add 'jira' to the agent_tasks.source CHECK constraint.
2
+ -- SQLite cannot ALTER an existing CHECK; we follow the table-rebuild pattern
3
+ -- (per migration 026). Column list mirrors the live post-042 schema verbatim
4
+ -- — including all post-026 additions (credentialKeySuffix/Type, requestedByUserId,
5
+ -- vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey).
6
+ -- INSERT uses an explicit column list (no `SELECT *`) to be robust against
7
+ -- column-order drift between SQLite versions.
8
+ PRAGMA foreign_keys=off;
9
+
10
+ CREATE TABLE agent_tasks_new (
11
+ id TEXT PRIMARY KEY,
12
+ agentId TEXT,
13
+ creatorAgentId TEXT,
14
+ task TEXT NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'pending',
16
+ source TEXT NOT NULL DEFAULT 'mcp' CHECK(source IN ('mcp', 'slack', 'api', 'github', 'gitlab', 'agentmail', 'system', 'schedule', 'workflow', 'linear', 'jira')),
17
+ taskType TEXT,
18
+ tags TEXT DEFAULT '[]',
19
+ priority INTEGER DEFAULT 50,
20
+ dependsOn TEXT DEFAULT '[]',
21
+ offeredTo TEXT,
22
+ offeredAt TEXT,
23
+ acceptedAt TEXT,
24
+ rejectionReason TEXT,
25
+ slackChannelId TEXT,
26
+ slackThreadTs TEXT,
27
+ slackUserId TEXT,
28
+ mentionMessageId TEXT,
29
+ mentionChannelId TEXT,
30
+ vcsProvider TEXT,
31
+ vcsRepo TEXT,
32
+ vcsEventType TEXT,
33
+ vcsNumber INTEGER,
34
+ vcsCommentId INTEGER,
35
+ vcsAuthor TEXT,
36
+ vcsUrl TEXT,
37
+ parentTaskId TEXT,
38
+ claudeSessionId TEXT,
39
+ agentmailInboxId TEXT,
40
+ agentmailMessageId TEXT,
41
+ agentmailThreadId TEXT,
42
+ model TEXT,
43
+ scheduleId TEXT,
44
+ workflowRunId TEXT REFERENCES workflow_runs(id),
45
+ workflowRunStepId TEXT REFERENCES workflow_run_steps(id),
46
+ createdAt TEXT NOT NULL,
47
+ lastUpdatedAt TEXT NOT NULL,
48
+ finishedAt TEXT,
49
+ failureReason TEXT,
50
+ output TEXT,
51
+ progress TEXT,
52
+ notifiedAt TEXT,
53
+ dir TEXT,
54
+ outputSchema TEXT,
55
+ compactionCount INTEGER DEFAULT 0,
56
+ peakContextPercent REAL,
57
+ totalContextTokensUsed INTEGER,
58
+ contextWindowSize INTEGER,
59
+ was_paused INTEGER NOT NULL DEFAULT 0,
60
+ credentialKeySuffix TEXT,
61
+ credentialKeyType TEXT,
62
+ requestedByUserId TEXT REFERENCES users(id),
63
+ vcsInstallationId INTEGER,
64
+ vcsNodeId TEXT,
65
+ slackReplySent INTEGER DEFAULT 0,
66
+ swarmVersion TEXT,
67
+ contextKey TEXT
68
+ );
69
+
70
+ INSERT INTO agent_tasks_new (
71
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
72
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
73
+ slackChannelId, slackThreadTs, slackUserId,
74
+ mentionMessageId, mentionChannelId,
75
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
76
+ parentTaskId, claudeSessionId,
77
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
78
+ model, scheduleId, workflowRunId, workflowRunStepId,
79
+ createdAt, lastUpdatedAt, finishedAt, failureReason, output, progress, notifiedAt,
80
+ dir, outputSchema, compactionCount, peakContextPercent,
81
+ totalContextTokensUsed, contextWindowSize, was_paused,
82
+ credentialKeySuffix, credentialKeyType, requestedByUserId,
83
+ vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey
84
+ )
85
+ SELECT
86
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
87
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
88
+ slackChannelId, slackThreadTs, slackUserId,
89
+ mentionMessageId, mentionChannelId,
90
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
91
+ parentTaskId, claudeSessionId,
92
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
93
+ model, scheduleId, workflowRunId, workflowRunStepId,
94
+ createdAt, lastUpdatedAt, finishedAt, failureReason, output, progress, notifiedAt,
95
+ dir, outputSchema, compactionCount, peakContextPercent,
96
+ totalContextTokensUsed, contextWindowSize, was_paused,
97
+ credentialKeySuffix, credentialKeyType, requestedByUserId,
98
+ vcsInstallationId, vcsNodeId, slackReplySent, swarmVersion, contextKey
99
+ FROM agent_tasks;
100
+
101
+ DROP TABLE agent_tasks;
102
+ ALTER TABLE agent_tasks_new RENAME TO agent_tasks;
103
+
104
+ -- Recreate every index that existed on agent_tasks (per `grep -rn "ON agent_tasks(" src/be/migrations/`):
105
+ -- 001/004/006/009/026: agentId, status, offeredTo, taskType, agentmailThreadId, scheduleId, workflowRunId
106
+ -- 031: requestedByUserId (partial)
107
+ -- 034: parentTaskId
108
+ -- 037: swarmVersion
109
+ -- 040: composite (slackChannelId, slackThreadTs, status)
110
+ -- 042: contextKey + (contextKey, status) composite
111
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentId ON agent_tasks(agentId);
112
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status);
113
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_offeredTo ON agent_tasks(offeredTo);
114
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_taskType ON agent_tasks(taskType);
115
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentmailThreadId ON agent_tasks(agentmailThreadId);
116
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_schedule_id ON agent_tasks(scheduleId);
117
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_workflow_run ON agent_tasks(workflowRunId);
118
+ CREATE INDEX IF NOT EXISTS idx_tasks_requested_by ON agent_tasks(requestedByUserId) WHERE requestedByUserId IS NOT NULL;
119
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_parentTaskId ON agent_tasks(parentTaskId);
120
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_swarmVersion ON agent_tasks(swarmVersion);
121
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_slack_thread
122
+ ON agent_tasks(slackChannelId, slackThreadTs, status);
123
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_context_key
124
+ ON agent_tasks(contextKey);
125
+ CREATE INDEX IF NOT EXISTS idx_agent_tasks_context_key_status
126
+ ON agent_tasks(contextKey, status);
127
+
128
+ PRAGMA foreign_keys=on;
@@ -272,6 +272,7 @@ const SWARM_TOOL_LABELS: Record<string, string | null> = {
272
272
  "update-profile": "🪪 Updating profile",
273
273
  // Slack
274
274
  "slack-post": "💬 Posting to Slack",
275
+ "slack-start-thread": "💬 Starting Slack thread",
275
276
  "slack-reply": "💬 Replying in Slack",
276
277
  "slack-read": "💬 Reading Slack",
277
278
  "slack-list-channels": "💬 Listing Slack channels",
@@ -2206,8 +2207,13 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2206
2207
  configureHttpResolver(apiUrl, process.env.API_KEY);
2207
2208
  }
2208
2209
 
2209
- // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
2210
- // Workers use HTTP-based config access (cannot import DB directly)
2210
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false).
2211
+ // Workers use HTTP-based config access (cannot import DB directly).
2212
+ // IMPORTANT: workers must NOT pass `generateIfMissing` — the api-server is
2213
+ // the sole authority for `telemetry_installation_id`. If the API hasn't
2214
+ // persisted one yet (network blip, fresh boot, API down), the worker simply
2215
+ // skips telemetry instead of minting a fresh `install_<hex>` ID per
2216
+ // restart, which floods prod metrics with phantom installs.
2211
2217
  {
2212
2218
  const telemetryApiKey = process.env.API_KEY;
2213
2219
  await initTelemetry(
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
- const IDENTITY_FILE_MIN_LENGTH = 100;
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