@desplega.ai/agent-swarm 2.7.2 → 2.7.3
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 +6 -0
- package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
- package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
- package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
- package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
- package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
- package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
- package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
- package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
- package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
- package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
- package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
- package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
- package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
- package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
- package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
- package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
- package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
- package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
- package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
- package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
- package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
- package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
- package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
- package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
- package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
- package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
- package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
- package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
- package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
- package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
- package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
- package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
- package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
- package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
- package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
- package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
- package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
- package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
- package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
- package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
- package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
- package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
- package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
- package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
- package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
- package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
- package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
- package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
- package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
- package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
- package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
- package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
- package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
- package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
- package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
- package/dist/cli.js +19 -19
- package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
- package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
- package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
- package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
- package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
- package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
- package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
- package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
- package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
- package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
- package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
- package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
- package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
- package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
- package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
- package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
- package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
- package/dist/handlers-ss5948rq.js +42 -0
- package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
- package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
- package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
- package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
- package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
- package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
- package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
- package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
- package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
- package/dist/index-z3b1vfjx.js +44 -0
- package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
- package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
- package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
- package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
- package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
- package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
- package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
- package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
- package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
- package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
- package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
- package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
- package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
- package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
- package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
- package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
- package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
- package/openapi.json +134 -3
- package/package.json +5 -5
- package/src/be/db/agents.ts +16 -1
- package/src/be/db/runtime.ts +25 -15
- package/src/be/db/session-summaries.ts +120 -0
- package/src/be/db/tasks/read.ts +7 -4
- package/src/be/db/tasks/search-index.ts +159 -0
- package/src/be/db/tasks/write.ts +3 -0
- package/src/be/db-queries/slack-inbound.ts +77 -0
- package/src/be/db.ts +27 -8
- package/src/be/mcp-overflow-sweep.ts +39 -0
- package/src/be/migrations/253_session_summaries.sql +300 -0
- package/src/be/migrations/254_query_audit_indexes.sql +25 -0
- package/src/be/migrations/256_agent_slack_icon.sql +2 -0
- package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
- package/src/comb/config.ts +6 -2
- package/src/comb/requester.ts +69 -0
- package/src/comb/review-batch.ts +24 -2
- package/src/commands/onboard/agent-fs-version.ts +1 -1
- package/src/commands/onboard/compose-generator.ts +7 -2
- package/src/commands/onboard/env-generator.ts +8 -3
- package/src/commands/runner.ts +41 -24
- package/src/fs/agent-fs-provider.ts +9 -0
- package/src/heartbeat/heartbeat.ts +13 -6
- package/src/http/agents.ts +10 -2
- package/src/http/index.ts +15 -0
- package/src/http/integrations.ts +20 -0
- package/src/http/list-total-cache.ts +62 -0
- package/src/http/sessions.ts +9 -2
- package/src/http/slack-inbound.ts +1 -0
- package/src/http/status.ts +4 -1
- package/src/http/tasks.ts +12 -2
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/queue-stall-alarm.ts +4 -1
- package/src/slack/actions.ts +2 -2
- package/src/slack/app-icon.ts +50 -0
- package/src/slack/app.ts +13 -1
- package/src/slack/assistant.ts +22 -1
- package/src/slack/event-dedup.test.ts +11 -0
- package/src/slack/event-dedup.ts +8 -4
- package/src/slack/inbound-dispatch.ts +58 -0
- package/src/slack/render-v2.ts +7 -8
- package/src/slack/responses.ts +21 -8
- package/src/slack/socket-ack.ts +237 -0
- package/src/slack/thread-buffer.ts +3 -5
- package/src/slack/watcher.ts +2 -2
- package/src/slack/work-objects.ts +5 -1
- package/src/tests/agent-fs-release-notes.test.ts +40 -0
- package/src/tests/agent-slack-icon.test.ts +85 -0
- package/src/tests/approval-cancellation-migration.test.ts +2 -0
- package/src/tests/asset-key-migration.test.ts +26 -0
- package/src/tests/comb-default.test.ts +159 -0
- package/src/tests/comb-review-batch.test.ts +192 -4
- package/src/tests/comb-status.test.ts +65 -2
- package/src/tests/db-agents.test.ts +13 -0
- package/src/tests/db-tasks-read.test.ts +10 -15
- package/src/tests/deploy-one-box.test.ts +161 -0
- package/src/tests/deploy-stubs.ts +68 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
- package/src/tests/fixtures/deploy-stubs/bun +3 -0
- package/src/tests/fixtures/deploy-stubs/bunx +27 -0
- package/src/tests/fixtures/deploy-stubs/curl +26 -0
- package/src/tests/fixtures/deploy-stubs/docker +122 -0
- package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
- package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
- package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
- package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
- package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
- package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
- package/src/tests/heartbeat-expiry.test.ts +151 -25
- package/src/tests/http-api-integration.test.ts +31 -0
- package/src/tests/human-free-reclassify.test.ts +6 -0
- package/src/tests/install-sh-bind.test.ts +242 -0
- package/src/tests/install-sh-tunnel.test.ts +89 -0
- package/src/tests/kv-storage.test.ts +28 -0
- package/src/tests/list-endpoint-slimming.test.ts +3 -0
- package/src/tests/list-total-cache.test.ts +63 -0
- package/src/tests/mcp-overflow-sweep.test.ts +84 -0
- package/src/tests/onboard-compose.test.ts +17 -3
- package/src/tests/onboard-env.test.ts +13 -0
- package/src/tests/onboarding.test.ts +1 -1
- package/src/tests/preload.ts +1 -0
- package/src/tests/query-audit-plans.test.ts +314 -0
- package/src/tests/script-connections.test.ts +6 -0
- package/src/tests/session-summaries.test.ts +384 -0
- package/src/tests/sessions-tree.test.ts +5 -1
- package/src/tests/slack-app-icon.test.ts +90 -0
- package/src/tests/slack-external-channel-egress.test.ts +1 -1
- package/src/tests/slack-inbound-receipts.test.ts +34 -0
- package/src/tests/slack-inline-output.test.ts +29 -2
- package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
- package/src/tests/slack-render-v2.test.ts +35 -0
- package/src/tests/slack-socket-ack.test.ts +553 -0
- package/src/tests/task-lineage.test.ts +3 -0
- package/src/tests/task-search-filter.test.ts +4 -4
- package/src/tests/task-search-fts.test.ts +320 -0
- package/src/tests/update-profile-auth.test.ts +28 -0
- package/src/tools/get-swarm.ts +1 -0
- package/src/tools/get-tasks.ts +6 -1
- package/src/tools/join-swarm.ts +1 -0
- package/src/tools/my-agent-info.ts +1 -0
- package/src/tools/slack-post.ts +2 -2
- package/src/tools/slack-reply.ts +2 -2
- package/src/tools/slack-start-thread.ts +2 -2
- package/src/tools/slack-update.ts +1 -1
- package/src/tools/update-profile.ts +20 -4
- package/src/tools/utils.ts +3 -4
- package/src/types.ts +47 -0
- package/src/utils/constants.ts +127 -5
- package/templates/official/lead/HEARTBEAT.md +4 -2
- package/templates/official/lead/config.json +2 -2
- package/templates/skills/swarm-docs/content.md +2 -0
- package/dist/handlers-7x8sywgr.js +0 -42
- package/dist/index-mc30r1hs.js +0 -44
package/src/commands/runner.ts
CHANGED
|
@@ -5682,6 +5682,38 @@ export async function checkCompletedProcesses(
|
|
|
5682
5682
|
|
|
5683
5683
|
const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
5684
5684
|
|
|
5685
|
+
async function saveGeneratedProfile({
|
|
5686
|
+
apiUrl,
|
|
5687
|
+
apiKey,
|
|
5688
|
+
agentId,
|
|
5689
|
+
role,
|
|
5690
|
+
profileUpdate,
|
|
5691
|
+
}: {
|
|
5692
|
+
apiUrl: string;
|
|
5693
|
+
apiKey: string;
|
|
5694
|
+
agentId: string;
|
|
5695
|
+
role: string;
|
|
5696
|
+
profileUpdate: Record<string, string>;
|
|
5697
|
+
}): Promise<void> {
|
|
5698
|
+
try {
|
|
5699
|
+
const response = await fetch(`${apiUrl}/api/agents/${agentId}/profile`, {
|
|
5700
|
+
method: "PUT",
|
|
5701
|
+
headers: {
|
|
5702
|
+
Authorization: `Bearer ${apiKey}`,
|
|
5703
|
+
"X-Agent-ID": agentId,
|
|
5704
|
+
"Content-Type": "application/json",
|
|
5705
|
+
},
|
|
5706
|
+
body: JSON.stringify(profileUpdate),
|
|
5707
|
+
});
|
|
5708
|
+
if (!response.ok) {
|
|
5709
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
5710
|
+
}
|
|
5711
|
+
console.log(`[${role}] Generated and saved default identity templates`);
|
|
5712
|
+
} catch (err) {
|
|
5713
|
+
console.warn(scrubSecrets(`[${role}] Could not save generated templates to server: ${err}`));
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5685
5717
|
async function fetchTemplate(
|
|
5686
5718
|
templateId: string,
|
|
5687
5719
|
registryUrl: string,
|
|
@@ -6534,30 +6566,15 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
6534
6566
|
if (!agentClaudeMd) agentClaudeMd = generateDefaultClaudeMd(agentInfo);
|
|
6535
6567
|
|
|
6536
6568
|
// Push generated templates to server
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
profileUpdate.heartbeatMd = agentHeartbeatMd;
|
|
6547
|
-
|
|
6548
|
-
await fetch(`${apiUrl}/api/agents/${agentId}/profile`, {
|
|
6549
|
-
method: "PUT",
|
|
6550
|
-
headers: {
|
|
6551
|
-
Authorization: `Bearer ${apiKey}`,
|
|
6552
|
-
"X-Agent-ID": agentId,
|
|
6553
|
-
"Content-Type": "application/json",
|
|
6554
|
-
},
|
|
6555
|
-
body: JSON.stringify(profileUpdate),
|
|
6556
|
-
});
|
|
6557
|
-
console.log(`[${role}] Generated and saved default identity templates`);
|
|
6558
|
-
} catch {
|
|
6559
|
-
console.warn(`[${role}] Could not save generated templates to server`);
|
|
6560
|
-
}
|
|
6569
|
+
const profileUpdate: Record<string, string> = {};
|
|
6570
|
+
if (!profile.soulMd) profileUpdate.soulMd = agentSoulMd;
|
|
6571
|
+
if (!profile.identityMd) profileUpdate.identityMd = agentIdentityMd;
|
|
6572
|
+
if (!profile.toolsMd) profileUpdate.toolsMd = agentToolsMd;
|
|
6573
|
+
if (!profile.claudeMd && agentClaudeMd) profileUpdate.claudeMd = agentClaudeMd;
|
|
6574
|
+
if (!profile.setupScript && agentSetupScript) profileUpdate.setupScript = agentSetupScript;
|
|
6575
|
+
if (!profile.heartbeatMd && agentHeartbeatMd) profileUpdate.heartbeatMd = agentHeartbeatMd;
|
|
6576
|
+
|
|
6577
|
+
await saveGeneratedProfile({ apiUrl, apiKey, agentId, role, profileUpdate });
|
|
6561
6578
|
}
|
|
6562
6579
|
|
|
6563
6580
|
// Fetch installed MCP servers for system prompt
|
|
@@ -66,6 +66,9 @@ export type AgentFsComment = {
|
|
|
66
66
|
/** One entry of a file's `log`. */
|
|
67
67
|
export type AgentFsFileVersion = { version: number; createdAt: string };
|
|
68
68
|
|
|
69
|
+
/** One member of a drive (`drive-members`). */
|
|
70
|
+
export type AgentFsDriveMember = { userId: string; email: string; displayName?: string | null };
|
|
71
|
+
|
|
69
72
|
/** An agent-fs org and drive. */
|
|
70
73
|
export type AgentFsDrive = { orgId: string; driveId: string };
|
|
71
74
|
|
|
@@ -300,6 +303,12 @@ export class AgentFsProvider implements FileStorageProvider {
|
|
|
300
303
|
return Array.isArray(result?.versions) ? (result.versions as AgentFsFileVersion[]) : [];
|
|
301
304
|
}
|
|
302
305
|
|
|
306
|
+
/** `drive-members`: the drive's members with their emails. Needs the agent-fs `drive-members` feature. */
|
|
307
|
+
async getDriveMembers(drive: AgentFsDrive): Promise<AgentFsDriveMember[]> {
|
|
308
|
+
const result = asRecord(await this.ops({ op: "drive-members" }, drive));
|
|
309
|
+
return Array.isArray(result?.members) ? (result.members as AgentFsDriveMember[]) : [];
|
|
310
|
+
}
|
|
311
|
+
|
|
303
312
|
/** Reply to a root comment. The swarm service account is the author. */
|
|
304
313
|
async replyToComment(
|
|
305
314
|
drive: AgentFsDrive,
|
|
@@ -76,7 +76,12 @@ import {
|
|
|
76
76
|
REBOOT_RETRY_PIN_TAG,
|
|
77
77
|
resolveLeadOnlyRecoveryAssignment,
|
|
78
78
|
} from "../tasks/worker-follow-up";
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
type AgentTask,
|
|
81
|
+
OPEN_TASK_STATUSES,
|
|
82
|
+
SUPERSEDED_WORKFLOW_TASK_REASON,
|
|
83
|
+
TERMINAL_TASK_STATUSES,
|
|
84
|
+
} from "../types";
|
|
80
85
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
81
86
|
import { isMultiRuntimeEnabled } from "../utils/multi-runtime";
|
|
82
87
|
import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
|
|
@@ -1800,14 +1805,16 @@ export async function checkHeartbeatChecklist(): Promise<void> {
|
|
|
1800
1805
|
|
|
1801
1806
|
if (isEffectivelyEmpty(heartbeatMd)) return;
|
|
1802
1807
|
|
|
1803
|
-
// Dedup: skip if lead already has an active heartbeat-checklist task
|
|
1808
|
+
// Dedup: skip if lead already has an active heartbeat-checklist task.
|
|
1809
|
+
// Unary + keeps agentId/taskType off their indexes, so the seek runs on
|
|
1810
|
+
// status over the few open tasks instead of every past checklist task.
|
|
1804
1811
|
const existing = await getDbClient().get<{ id: string }>(
|
|
1805
1812
|
`SELECT id FROM agent_tasks
|
|
1806
|
-
WHERE
|
|
1807
|
-
AND
|
|
1808
|
-
AND
|
|
1813
|
+
WHERE status IN (${OPEN_TASK_STATUSES.map(() => "?").join(", ")})
|
|
1814
|
+
AND +agentId = ?
|
|
1815
|
+
AND +taskType = 'heartbeat-checklist'
|
|
1809
1816
|
LIMIT 1`,
|
|
1810
|
-
[lead.id
|
|
1817
|
+
[...OPEN_TASK_STATUSES, lead.id],
|
|
1811
1818
|
);
|
|
1812
1819
|
if (existing) return;
|
|
1813
1820
|
|
package/src/http/agents.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
AgentCredStatusSchema,
|
|
62
62
|
AgentLatestModelSchema,
|
|
63
63
|
AgentSchema,
|
|
64
|
+
AgentSlackIconSchema,
|
|
64
65
|
AgentStatusSchema,
|
|
65
66
|
AgentWithTasksSchema,
|
|
66
67
|
DeliveryModeSchema,
|
|
@@ -530,6 +531,11 @@ const updateAgentProfileRoute = route({
|
|
|
530
531
|
heartbeatMd: z.string().max(MAX_PROFILE_FILE_LENGTH).optional(),
|
|
531
532
|
/** `null` resets to the deterministic fallback; omit the key to leave untouched. */
|
|
532
533
|
avatar: AgentAvatarSchema.nullable().optional(),
|
|
534
|
+
// z.lazy keeps `| null` in the OpenAPI body: a named schema's $ref drops it.
|
|
535
|
+
slackIcon: z
|
|
536
|
+
.lazy(() => AgentSlackIconSchema)
|
|
537
|
+
.nullable()
|
|
538
|
+
.optional(),
|
|
533
539
|
changeSource: z.string().optional(),
|
|
534
540
|
changedByAgentId: z.string().optional(),
|
|
535
541
|
changeReason: z.string().optional(),
|
|
@@ -976,11 +982,12 @@ export async function handleAgentsRest(
|
|
|
976
982
|
body.setupScript === undefined &&
|
|
977
983
|
body.toolsMd === undefined &&
|
|
978
984
|
body.heartbeatMd === undefined &&
|
|
979
|
-
body.avatar === undefined
|
|
985
|
+
body.avatar === undefined &&
|
|
986
|
+
body.slackIcon === undefined
|
|
980
987
|
) {
|
|
981
988
|
jsonError(
|
|
982
989
|
res,
|
|
983
|
-
"At least one field (role, description, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, heartbeatMd, or
|
|
990
|
+
"At least one field (role, description, capabilities, claudeMd, soulMd, identityMd, setupScript, toolsMd, heartbeatMd, avatar, or slackIcon) must be provided",
|
|
984
991
|
400,
|
|
985
992
|
);
|
|
986
993
|
return true;
|
|
@@ -1017,6 +1024,7 @@ export async function handleAgentsRest(
|
|
|
1017
1024
|
// Only include the key when the client sent it, so `null` (reset)
|
|
1018
1025
|
// is distinguishable from "not provided" (leave untouched).
|
|
1019
1026
|
...(body.avatar !== undefined ? { avatar: body.avatar } : {}),
|
|
1027
|
+
...(body.slackIcon !== undefined ? { slackIcon: body.slackIcon } : {}),
|
|
1020
1028
|
},
|
|
1021
1029
|
versionMeta,
|
|
1022
1030
|
{
|
package/src/http/index.ts
CHANGED
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
getSwarmConfigs,
|
|
16
16
|
upsertSwarmConfig,
|
|
17
17
|
} from "../be/db";
|
|
18
|
+
import { startTaskSearchBackfill, stopTaskSearchBackfill } from "../be/db/tasks/search-index";
|
|
18
19
|
import { startDbRetention, stopDbRetention } from "../be/db-retention";
|
|
19
20
|
import { startHumanFreeDrain, stopHumanFreeDrain } from "../be/human-free-drain";
|
|
21
|
+
import { startMcpOverflowSweep, stopMcpOverflowSweep } from "../be/mcp-overflow-sweep";
|
|
20
22
|
import {
|
|
21
23
|
enqueueAuditRow,
|
|
22
24
|
flushAuditBuffer,
|
|
@@ -528,6 +530,9 @@ async function shutdown() {
|
|
|
528
530
|
// Stop the unified OAuth pending-session garbage collector (all flows)
|
|
529
531
|
stopOAuthPendingGc();
|
|
530
532
|
|
|
533
|
+
// Stop the expired MCP overflow sweep
|
|
534
|
+
stopMcpOverflowSweep();
|
|
535
|
+
|
|
531
536
|
// Stop memory expired-row garbage collector
|
|
532
537
|
stopMemoryGc();
|
|
533
538
|
|
|
@@ -537,6 +542,9 @@ async function shutdown() {
|
|
|
537
542
|
// Stop the human-free reclassification drain before closing SQLite.
|
|
538
543
|
stopHumanFreeDrain();
|
|
539
544
|
|
|
545
|
+
// Stop the task search index backfill after its current batch.
|
|
546
|
+
await stopTaskSearchBackfill();
|
|
547
|
+
|
|
540
548
|
// Stop scratch-script retention garbage collector
|
|
541
549
|
stopScratchScriptGc();
|
|
542
550
|
|
|
@@ -866,6 +874,9 @@ httpServer
|
|
|
866
874
|
// Start the unified OAuth pending-session garbage collector (5-min tick, all flows)
|
|
867
875
|
startOAuthPendingGc();
|
|
868
876
|
|
|
877
|
+
// Delete expired MCP overflow values (10-min tick, off the spill path)
|
|
878
|
+
startMcpOverflowSweep();
|
|
879
|
+
|
|
869
880
|
// Start expired-memory garbage collector (1-hour tick, immediate first run)
|
|
870
881
|
await startMemoryGc();
|
|
871
882
|
|
|
@@ -877,6 +888,10 @@ httpServer
|
|
|
877
888
|
// task tree, or a restart mid-drain). Wakes on demand; see human-free-drain.ts.
|
|
878
889
|
startHumanFreeDrain();
|
|
879
890
|
|
|
891
|
+
// Index the task rows migration 256 left out, in batches. Task search
|
|
892
|
+
// stays on LIKE until it finishes. See src/be/db/tasks/search-index.ts.
|
|
893
|
+
void startTaskSearchBackfill();
|
|
894
|
+
|
|
880
895
|
// (RBAC audit sink is wired pre-listen — see above httpServer.listen.)
|
|
881
896
|
|
|
882
897
|
// Background embedding maintenance: re-embed every memory and script
|
package/src/http/integrations.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getChatSdkStatus,
|
|
8
8
|
registerTelegramWebhook,
|
|
9
9
|
} from "../integrations/chat-sdk/status";
|
|
10
|
+
import { getSlackAppIconUrl } from "../slack/app-icon";
|
|
10
11
|
import { buildSlackManifest } from "../slack/manifest";
|
|
11
12
|
import { selectCredential } from "../utils/credentials";
|
|
12
13
|
import { ensureHttpPermission } from "./config";
|
|
@@ -117,6 +118,20 @@ const slackManifestRoute = route({
|
|
|
117
118
|
},
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
const slackAppIconRoute = route({
|
|
122
|
+
method: "get",
|
|
123
|
+
path: "/api/integrations/slack/app-icon",
|
|
124
|
+
pattern: ["api", "integrations", "slack", "app-icon"],
|
|
125
|
+
summary: "The Slack app's own avatar, shown on posts with no icon override",
|
|
126
|
+
tags: ["Integrations"],
|
|
127
|
+
responses: {
|
|
128
|
+
200: {
|
|
129
|
+
description: "`imageUrl` is null when Slack is off or the lookup fails.",
|
|
130
|
+
schema: z.object({ imageUrl: z.string().nullable() }),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
120
135
|
const chatSdkStatusRoute = route({
|
|
121
136
|
method: "get",
|
|
122
137
|
path: "/api/integrations/chat/status",
|
|
@@ -176,6 +191,11 @@ export function createIntegrationsHandler(deps: TestConnectionDeps = {}) {
|
|
|
176
191
|
return true;
|
|
177
192
|
}
|
|
178
193
|
|
|
194
|
+
if (slackAppIconRoute.match(req.method, pathSegments)) {
|
|
195
|
+
slackAppIconRoute.respond(res, 200, { imageUrl: await getSlackAppIconUrl() });
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
179
199
|
if (chatSdkStatusRoute.match(req.method, pathSegments)) {
|
|
180
200
|
chatSdkStatusRoute.respond(res, 200, await getChatSdkStatus());
|
|
181
201
|
return true;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A 30-second cache of list `total`s, keyed by filter set.
|
|
3
|
+
*
|
|
4
|
+
* A list total is a COUNT over the same filters as the page, and on the large
|
|
5
|
+
* tables behind the sessions and task lists it costs as much as the page
|
|
6
|
+
* itself. Page 1 (offset 0) always counts, so a write (a new session, a
|
|
7
|
+
* triage move) shows at once where the user looks first. Later pages reuse
|
|
8
|
+
* that count for 30 s. A cold later page (deep link, expired entry) counts
|
|
9
|
+
* too, so `total` is always set.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const LIST_TOTAL_TTL_MS = 30_000;
|
|
13
|
+
const MAX_ENTRIES = 500;
|
|
14
|
+
|
|
15
|
+
interface Entry {
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
total: Promise<number>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListTotalCache {
|
|
21
|
+
get(
|
|
22
|
+
filters: Record<string, unknown>,
|
|
23
|
+
offset: number | undefined,
|
|
24
|
+
compute: () => Promise<number>,
|
|
25
|
+
): Promise<number>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stable key: sorted object keys, `undefined` fields dropped. */
|
|
29
|
+
function filterKey(value: unknown): string {
|
|
30
|
+
return JSON.stringify(value, (_key, v) =>
|
|
31
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
32
|
+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
|
|
33
|
+
: v,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createListTotalCache(ttlMs = LIST_TOTAL_TTL_MS): ListTotalCache {
|
|
38
|
+
const entries = new Map<string, Entry>();
|
|
39
|
+
return {
|
|
40
|
+
get(filters, offset, compute) {
|
|
41
|
+
const key = filterKey(filters);
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const hit = entries.get(key);
|
|
44
|
+
if (hit && hit.expiresAt > now && (offset ?? 0) > 0) return hit.total;
|
|
45
|
+
if (hit) entries.delete(key);
|
|
46
|
+
|
|
47
|
+
// Cache the promise, so a later page that arrives mid-count shares it.
|
|
48
|
+
const total = compute();
|
|
49
|
+
entries.set(key, { expiresAt: now + ttlMs, total });
|
|
50
|
+
total.catch(() => {
|
|
51
|
+
if (entries.get(key)?.total === total) entries.delete(key);
|
|
52
|
+
});
|
|
53
|
+
// Map iterates in insertion order, so the first key is the oldest.
|
|
54
|
+
while (entries.size > MAX_ENTRIES) {
|
|
55
|
+
const oldest = entries.keys().next().value;
|
|
56
|
+
if (oldest === undefined) break;
|
|
57
|
+
entries.delete(oldest);
|
|
58
|
+
}
|
|
59
|
+
return total;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/http/sessions.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
import { getRequestAuth } from "../utils/request-auth-context";
|
|
76
76
|
import { isRuntimePoolsEnabled } from "../utils/runtime-pools-flag";
|
|
77
77
|
import { canMutateTaskNamespace } from "./assets";
|
|
78
|
+
import { createListTotalCache } from "./list-total-cache";
|
|
78
79
|
import { agentFirstPrincipal, authUserId, requireAuthKind } from "./request-principal";
|
|
79
80
|
import { route } from "./route-def";
|
|
80
81
|
import {
|
|
@@ -538,6 +539,8 @@ const SessionShowSourceSchema = z
|
|
|
538
539
|
"Comma-separated root sources to keep (the sessions v2 source filter), e.g. `ui,slack`. Present and empty keeps none; omit to keep every source.",
|
|
539
540
|
);
|
|
540
541
|
|
|
542
|
+
const sessionTotals = createListTotalCache();
|
|
543
|
+
|
|
541
544
|
const listSessions = route({
|
|
542
545
|
method: "get",
|
|
543
546
|
path: "/api/sessions",
|
|
@@ -1017,8 +1020,12 @@ export async function handleSessions(
|
|
|
1017
1020
|
? await listRecentSessions(baseOpts)
|
|
1018
1021
|
: await listRecentSessions({ ...baseOpts, slim: true });
|
|
1019
1022
|
// Filter-aware total: same WHERE as the list query, so the UI pager
|
|
1020
|
-
// reflects the filtered result set.
|
|
1021
|
-
|
|
1023
|
+
// reflects the filtered result set. Page 1 counts; later pages reuse it
|
|
1024
|
+
// for 30 s (keyed without the request clock).
|
|
1025
|
+
const { now: _now, ...totalKey } = filterOpts;
|
|
1026
|
+
const total = await sessionTotals.get(totalKey, parsed.query.offset, () =>
|
|
1027
|
+
countSessions(filterOpts),
|
|
1028
|
+
);
|
|
1022
1029
|
listSessions.respond(res, 200, {
|
|
1023
1030
|
sessions,
|
|
1024
1031
|
total,
|
package/src/http/status.ts
CHANGED
|
@@ -134,6 +134,8 @@ export type StatusActivity = z.infer<typeof StatusActivitySchema>;
|
|
|
134
134
|
|
|
135
135
|
export const StatusCombSchema = z.object({
|
|
136
136
|
enabled: z.boolean(),
|
|
137
|
+
/** What is missing when Comb is off (for example no browser-reachable agent-fs URL). Null when on. */
|
|
138
|
+
reason: z.string().nullable(),
|
|
137
139
|
api_url: z.string().nullable(),
|
|
138
140
|
live_url: z.string(),
|
|
139
141
|
org_id: z.string().nullable(),
|
|
@@ -777,6 +779,7 @@ function getCombStatus(serviceUserId: string | null): StatusAgentFs["comb"] {
|
|
|
777
779
|
const comb = getCombConfig();
|
|
778
780
|
return {
|
|
779
781
|
enabled: comb.enabled,
|
|
782
|
+
reason: comb.reason,
|
|
780
783
|
api_url: comb.apiUrl,
|
|
781
784
|
live_url: comb.liveUrl,
|
|
782
785
|
org_id: comb.orgId,
|
|
@@ -811,7 +814,7 @@ const getStatus = route({
|
|
|
811
814
|
pattern: ["status"],
|
|
812
815
|
summary: "Identity + setup readiness + live activity for the swarm dashboard",
|
|
813
816
|
description:
|
|
814
|
-
"Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; setup milestones each emit `unverified | configured | verified`; automations report `running | needs_setup` from the same runtime preflight used at dispatch; activity counts agents alive in the last 5 min, leads that can take work (status idle or busy), and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set, plus the Comb settings (
|
|
817
|
+
"Single source of truth consumed by the UI home page. Identity comes from SWARM_* envs; setup milestones each emit `unverified | configured | verified`; automations report `running | needs_setup` from the same runtime preflight used at dispatch; activity counts agents alive in the last 5 min, leads that can take work (status idle or busy), and tasks created in the last 24h; agent_fs reports whether AGENT_FS_API_URL is set, plus the Comb settings (whether Files is on and, when off, what is missing; the browser-facing agent-fs URL, the shared org and drive ids, and the agent-fs user id of the swarm service account); telemetry reports the effective ANONYMIZED_TELEMETRY opt-out state.",
|
|
815
818
|
tags: ["Status"],
|
|
816
819
|
responses: {
|
|
817
820
|
200: { description: "Status payload", schema: StatusResponseSchema },
|
package/src/http/tasks.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
import { ensure } from "../utils/business-use";
|
|
85
85
|
import { getRequestAuth } from "../utils/request-auth-context";
|
|
86
86
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
87
|
+
import { createListTotalCache } from "./list-total-cache";
|
|
87
88
|
import {
|
|
88
89
|
agentFirstPrincipal,
|
|
89
90
|
authUserId,
|
|
@@ -223,6 +224,8 @@ const SupersedeTaskResponseSchema = z.object({
|
|
|
223
224
|
|
|
224
225
|
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
225
226
|
|
|
227
|
+
const taskTotals = createListTotalCache();
|
|
228
|
+
|
|
226
229
|
const listTasks = route({
|
|
227
230
|
method: "get",
|
|
228
231
|
path: "/api/tasks",
|
|
@@ -240,7 +243,12 @@ const listTasks = route({
|
|
|
240
243
|
workflowRunId: z.string().uuid().optional(),
|
|
241
244
|
key: AssetKeySchema.optional(),
|
|
242
245
|
keyPrefix: AssetKeySchema.optional(),
|
|
243
|
-
search: z
|
|
246
|
+
search: z
|
|
247
|
+
.string()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe(
|
|
250
|
+
"Search task text and title by word or phrase, with a prefix match on the last word, or a task id by prefix. Not a substring match: '2443' does not find '12443', and '%' and '_' are not wildcards.",
|
|
251
|
+
),
|
|
244
252
|
includeHeartbeat: z.enum(["true", "false"]).optional(),
|
|
245
253
|
/** ISO 8601 — return only tasks created on/after this timestamp. */
|
|
246
254
|
createdAfter: z.string().datetime().optional(),
|
|
@@ -1015,8 +1023,10 @@ export async function handleTasks(
|
|
|
1015
1023
|
? await getAllTasks(filters, { fields: "timeline" })
|
|
1016
1024
|
: await getAllTasks(filters, { slim: true });
|
|
1017
1025
|
// The COUNT(*) re-scans every matching row, so only pagers pay for it.
|
|
1026
|
+
// Page 1 counts; later pages reuse that count for 30 s.
|
|
1018
1027
|
if (parsed.query.includeTotal === "true") {
|
|
1019
|
-
const
|
|
1028
|
+
const { limit: _limit, offset: _offset, orderBy: _orderBy, ...totalKey } = filters;
|
|
1029
|
+
const total = await taskTotals.get(totalKey, filters.offset, () => getTasksCount(filters));
|
|
1020
1030
|
listTasks.respond(res, 200, { tasks, total });
|
|
1021
1031
|
} else {
|
|
1022
1032
|
listTasks.respond(res, 200, { tasks });
|
|
@@ -45,7 +45,7 @@ import type {
|
|
|
45
45
|
* default; the image installs exactly this version and the per-task settings
|
|
46
46
|
* set `amp.updates.mode: "disabled"`. Bump it deliberately, with a live run.
|
|
47
47
|
*/
|
|
48
|
-
export const AMP_PACKAGE = "@ampcode/cli@0.0.
|
|
48
|
+
export const AMP_PACKAGE = "@ampcode/cli@0.0.1793160055-gbd79f2";
|
|
49
49
|
|
|
50
50
|
/** MCP server name. Amp exposes its tools as `agent_swarm` / `store_progress` (dashes become underscores). */
|
|
51
51
|
const AMP_MCP_SERVER_NAME = "agent-swarm";
|
|
@@ -35,7 +35,7 @@ import type {
|
|
|
35
35
|
} from "./types";
|
|
36
36
|
|
|
37
37
|
/** Pinned in package.json and Dockerfile.worker; bump all three together. */
|
|
38
|
-
export const CURSOR_SDK_VERSION = "1.
|
|
38
|
+
export const CURSOR_SDK_VERSION = "1.2.0";
|
|
39
39
|
|
|
40
40
|
/** MCP server name; Cursor reports its tools as `mcp` calls with this `providerIdentifier`. */
|
|
41
41
|
const CURSOR_MCP_SERVER_NAME = "agent-swarm";
|
|
@@ -37,7 +37,7 @@ import type {
|
|
|
37
37
|
} from "./types";
|
|
38
38
|
|
|
39
39
|
// npm's `latest` is older and lacks the JSON/stdin contract used here.
|
|
40
|
-
export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.
|
|
40
|
+
export const DSH_PACKAGE = "@deepseek-ai/dsh@0.2.2-alpha.2";
|
|
41
41
|
|
|
42
42
|
export function checkDshCredentials(env: Record<string, string | undefined>): CredStatus {
|
|
43
43
|
return env.DEEPSEEK_API_KEY?.trim() || env.OPENROUTER_API_KEY?.trim()
|
package/src/queue-stall-alarm.ts
CHANGED
|
@@ -86,12 +86,15 @@ export async function getQueueStallSnapshot(now = new Date()): Promise<QueueStal
|
|
|
86
86
|
);
|
|
87
87
|
|
|
88
88
|
const pickupCutoff = new Date(now.getTime() - QUEUE_STALL_THRESHOLD_MS).toISOString();
|
|
89
|
+
// Unary + keeps eventType off idx_agent_log_eventType, so the short
|
|
90
|
+
// createdAt window drives through idx_agent_log_createdAt instead of every
|
|
91
|
+
// status-change row ever logged.
|
|
89
92
|
const recentPickupCount =
|
|
90
93
|
(
|
|
91
94
|
await getDbClient().get<{ count: number }>(
|
|
92
95
|
`SELECT COUNT(*) AS count
|
|
93
96
|
FROM agent_log
|
|
94
|
-
WHERE eventType IN ('task_status_change', 'task_claimed')
|
|
97
|
+
WHERE +eventType IN ('task_status_change', 'task_claimed')
|
|
95
98
|
AND oldValue IN ('pending', 'unassigned')
|
|
96
99
|
AND newValue = 'in_progress'
|
|
97
100
|
AND createdAt >= ?`,
|
package/src/slack/actions.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { resolveSlackUserId } from "./enrich";
|
|
|
8
8
|
import { EXTERNAL_CHANNEL_BLOCKED_REPLY, shouldBlockChannel } from "./external-channel";
|
|
9
9
|
import { ignoreSlackInbound, noteSlackInboundSideEffect } from "./inbound-dispatch";
|
|
10
10
|
import { ensureSlackThreadTree, isSlackRenderV2Enabled } from "./render-v2";
|
|
11
|
-
import { getAgentDisplayName,
|
|
11
|
+
import { getAgentDisplayName, getAgentSlackIcon } from "./responses";
|
|
12
12
|
|
|
13
13
|
type ChatUpdatePayload = ChatUpdateArguments & {
|
|
14
14
|
unfurl_links: false;
|
|
@@ -153,7 +153,7 @@ export function registerActionHandlers(app: App): void {
|
|
|
153
153
|
text: `💬 Follow-up sent to *${agentName}* (${taskLink})`,
|
|
154
154
|
unfurl_links: false,
|
|
155
155
|
unfurl_media: false,
|
|
156
|
-
...(lead ? { username: getAgentDisplayName(lead),
|
|
156
|
+
...(lead ? { username: getAgentDisplayName(lead), ...getAgentSlackIcon(lead) } : {}),
|
|
157
157
|
});
|
|
158
158
|
} catch (error) {
|
|
159
159
|
console.error("[Slack] Failed to post follow-up confirmation:", error);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { WebClient } from "@slack/web-api";
|
|
2
|
+
import { getSlackApp } from "./app";
|
|
3
|
+
|
|
4
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
5
|
+
// Per request, with no retries: the lookup ends within two of these, and an
|
|
6
|
+
// aborted auth.test never goes on to bots.info.
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 2_500;
|
|
8
|
+
|
|
9
|
+
// Keyed by token and API URL, so a reconfigured Slack app is looked up again.
|
|
10
|
+
let cached: { key: string; url: string | null; at: number } | null = null;
|
|
11
|
+
let inFlight: { key: string; promise: Promise<string | null> } | null = null;
|
|
12
|
+
|
|
13
|
+
async function lookUp(client: WebClient): Promise<string | null> {
|
|
14
|
+
const auth = await client.auth.test();
|
|
15
|
+
if (!auth.bot_id) return null;
|
|
16
|
+
const info = await client.bots.info({ bot: auth.bot_id });
|
|
17
|
+
const icons = info.bot?.icons;
|
|
18
|
+
return icons?.image_72 ?? icons?.image_48 ?? icons?.image_36 ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The avatar Slack shows for this app's posts when no icon is overridden
|
|
23
|
+
* (`bots.info` for the bot behind the token). Null when Slack is off or the
|
|
24
|
+
* lookup fails. Cached for an hour; failures are not cached.
|
|
25
|
+
*/
|
|
26
|
+
export async function getSlackAppIconUrl(): Promise<string | null> {
|
|
27
|
+
// Slack can be turned off at runtime, so check it before the cache.
|
|
28
|
+
const app = getSlackApp()?.client;
|
|
29
|
+
if (!app?.token) return null;
|
|
30
|
+
const key = `${app.slackApiUrl} ${app.token}`;
|
|
31
|
+
if (cached?.key === key && Date.now() - cached.at < TTL_MS) return cached.url;
|
|
32
|
+
if (inFlight?.key === key) return await inFlight.promise;
|
|
33
|
+
const client = new WebClient(app.token, {
|
|
34
|
+
slackApiUrl: app.slackApiUrl,
|
|
35
|
+
retryConfig: { retries: 0 },
|
|
36
|
+
rejectRateLimitedCalls: true,
|
|
37
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
38
|
+
});
|
|
39
|
+
const promise: Promise<string | null> = lookUp(client)
|
|
40
|
+
.then((url) => {
|
|
41
|
+
cached = { key, url, at: Date.now() };
|
|
42
|
+
return url;
|
|
43
|
+
})
|
|
44
|
+
.catch(() => null)
|
|
45
|
+
.finally(() => {
|
|
46
|
+
if (inFlight?.promise === promise) inFlight = null;
|
|
47
|
+
});
|
|
48
|
+
inFlight = { key, promise };
|
|
49
|
+
return await promise;
|
|
50
|
+
}
|
package/src/slack/app.ts
CHANGED
|
@@ -5,13 +5,15 @@ import { getSlackConfiguration } from "./config";
|
|
|
5
5
|
import { type SlackConnectionState, setSlackConnectionState } from "./connection-state";
|
|
6
6
|
import { installSlackEgressScrub } from "./egress-scrub";
|
|
7
7
|
import { installSlackExternalChannelGuard } from "./external-channel-egress";
|
|
8
|
-
import { slackInboundOutcomeMiddleware } from "./inbound-dispatch";
|
|
8
|
+
import { recoverSlackInboundAfterRestart, slackInboundOutcomeMiddleware } from "./inbound-dispatch";
|
|
9
|
+
import { installSocketAckRecovery } from "./socket-ack";
|
|
9
10
|
import { getSlackSocketModeBlockReason, SLACK_DEV_SOCKET_MODE_OPT_IN } from "./socket-mode-guard";
|
|
10
11
|
import { startTaskWatcher, stopTaskWatcher } from "./watcher";
|
|
11
12
|
|
|
12
13
|
let app: App | null = null;
|
|
13
14
|
let receiver: SocketModeReceiver | null = null;
|
|
14
15
|
let initialized = false;
|
|
16
|
+
let inboundDrain: ReturnType<typeof installSocketAckRecovery> | null = null;
|
|
15
17
|
|
|
16
18
|
const SOCKET_EVENT_STATES: Record<string, SlackConnectionState> = {
|
|
17
19
|
connecting: "connecting",
|
|
@@ -105,6 +107,10 @@ export async function initSlackApp(): Promise<App | null> {
|
|
|
105
107
|
...(clientOptions ? { clientOptions } : {}),
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
// Events whose Socket Mode ack failed are stored as receipts; the drain hands
|
|
111
|
+
// them back to Bolt, so a crash or a failing listener leaves recoverable work.
|
|
112
|
+
inboundDrain = installSocketAckRecovery(receiver.client, app);
|
|
113
|
+
|
|
108
114
|
// Failed validation must remain retryable without requiring stopSlackApp().
|
|
109
115
|
initialized = true;
|
|
110
116
|
|
|
@@ -147,8 +153,12 @@ export async function startSlackApp(): Promise<boolean> {
|
|
|
147
153
|
// Establish the durable cutoff before Socket Mode can deliver new asks.
|
|
148
154
|
const { isSlackRenderV2Enabled } = await import("./render-v2");
|
|
149
155
|
if (isSlackRenderV2Enabled()) await ensureSlackRenderV2Activation();
|
|
156
|
+
// Receipts stored before the last restart: interrupted ones turn uncertain,
|
|
157
|
+
// pending ones are processed once the app can dispatch them.
|
|
158
|
+
await recoverSlackInboundAfterRestart();
|
|
150
159
|
await app.start();
|
|
151
160
|
console.log("[Slack] Bot connected via Socket Mode");
|
|
161
|
+
void inboundDrain?.wake();
|
|
152
162
|
await emitBuiltInIntegrationConnectedOnce("slack");
|
|
153
163
|
|
|
154
164
|
// Start watching for task completions
|
|
@@ -161,6 +171,8 @@ export async function startSlackApp(): Promise<boolean> {
|
|
|
161
171
|
|
|
162
172
|
export async function stopSlackApp(): Promise<void> {
|
|
163
173
|
stopTaskWatcher();
|
|
174
|
+
inboundDrain?.stop();
|
|
175
|
+
inboundDrain = null;
|
|
164
176
|
|
|
165
177
|
if (app) {
|
|
166
178
|
await app.stop();
|
package/src/slack/assistant.ts
CHANGED
|
@@ -46,9 +46,24 @@ async function isExternalAssistantThread(
|
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
type ThreadContextStore = NonNullable<
|
|
50
|
+
ConstructorParameters<typeof Assistant>[0]["threadContextStore"]
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
/** `threadContextStore` replaces Bolt's metadata-backed default, which calls the Slack API. */
|
|
54
|
+
export function createAssistant(
|
|
55
|
+
options: { threadContextStore?: ThreadContextStore } = {},
|
|
56
|
+
): Assistant {
|
|
50
57
|
return new Assistant({
|
|
58
|
+
...(options.threadContextStore ? { threadContextStore: options.threadContextStore } : {}),
|
|
51
59
|
threadStarted: async ({ event, body, client, say, setSuggestedPrompts, saveThreadContext }) => {
|
|
60
|
+
// The greeting is not idempotent: a redelivery after a tolerated socket ack
|
|
61
|
+
// failure must not post it twice.
|
|
62
|
+
const eventId = body?.event_id;
|
|
63
|
+
if (!isAdmittedSlackDelivery() && wasEventSeen(eventId)) {
|
|
64
|
+
console.log(`[Slack] dropping Slack retry: event_id=${eventId}`);
|
|
65
|
+
return ignoreSlackInbound("duplicate_event");
|
|
66
|
+
}
|
|
52
67
|
if (await isExternalAssistantThread(client, event.assistant_thread.channel_id, body)) {
|
|
53
68
|
return ignoreSlackInbound("external_channel");
|
|
54
69
|
}
|
|
@@ -75,6 +90,12 @@ export function createAssistant(): Assistant {
|
|
|
75
90
|
},
|
|
76
91
|
|
|
77
92
|
threadContextChanged: async ({ event, body, client, saveThreadContext }) => {
|
|
93
|
+
// Saves are order-dependent: a stale redelivery would overwrite newer context.
|
|
94
|
+
const eventId = body?.event_id;
|
|
95
|
+
if (!isAdmittedSlackDelivery() && wasEventSeen(eventId)) {
|
|
96
|
+
console.log(`[Slack] dropping Slack retry: event_id=${eventId}`);
|
|
97
|
+
return ignoreSlackInbound("duplicate_event");
|
|
98
|
+
}
|
|
78
99
|
if (await isExternalAssistantThread(client, event.assistant_thread.channel_id, body)) {
|
|
79
100
|
return ignoreSlackInbound("external_channel");
|
|
80
101
|
}
|