@desplega.ai/agent-swarm 2.7.5 → 2.7.6
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/dist/{acp-adapter-g3ads55s.js → acp-adapter-bp91d37f.js} +4 -4
- package/dist/{actions-zwfykqqk.js → actions-yj0eeaqh.js} +18 -17
- package/dist/{amp-adapter-z3s0ssdt.js → amp-adapter-4pg9h34q.js} +1 -1
- package/dist/{app-vmp4fgep.js → app-909e97cr.js} +3 -3
- package/dist/{assistant-vwde653f.js → assistant-qdyb5fyy.js} +24 -22
- package/dist/{boot-reembed-375t7tbk.js → boot-reembed-fk0s9z18.js} +1 -1
- package/dist/{boot-reembed-gq9rcpnj.js → boot-reembed-gs1rzrnh.js} +1 -1
- package/dist/{boot-scrub-sweep-nd938kyf.js → boot-scrub-sweep-4qfd4kfx.js} +2 -2
- package/dist/{boot-seeds-aykx51v3.js → boot-seeds-yr00k13d.js} +4 -4
- package/dist/{boxd-k1j26g3h.js → boxd-vdmfssd9.js} +4 -4
- package/dist/{claude-adapter-8j4hm099.js → claude-adapter-qta1f1xn.js} +3 -3
- package/dist/{claude-managed-setup-0wyynpn1.js → claude-managed-setup-sc6j4dm0.js} +1 -1
- package/dist/{claude-sdk-session-q6wj5hmy.js → claude-sdk-session-zgrz6yvc.js} +9169 -9070
- package/dist/{cli-gjr2y2xk.js → cli-0agcv1v5.js} +4 -1
- package/dist/{cli-vqypxzh4.js → cli-0at8w34w.js} +7 -7
- package/dist/{cli-7a42nfy0.js → cli-0qatpy8q.js} +3 -3
- package/dist/{cli-p599f1qm.js → cli-0yv5z2pg.js} +1 -1
- package/dist/{cli-0gey5fzy.js → cli-386tyxxg.js} +2 -2
- package/dist/{cli-5pag6rmn.js → cli-3ypk9vk1.js} +1 -1
- package/dist/{cli-qxnf8z2z.js → cli-436532ck.js} +2 -2
- package/dist/{cli-z8eh3ex8.js → cli-4necwtsv.js} +4 -4
- package/dist/{cli-zh3fp8v2.js → cli-54t00gt8.js} +1 -1
- package/dist/{cli-hf6zt5m6.js → cli-69szn7w7.js} +17 -1
- package/dist/{cli-nn4d8dne.js → cli-89knwags.js} +2 -2
- package/dist/{cli-spknvxbq.js → cli-8bc1w0tw.js} +10 -10
- package/dist/{cli-rqkxrmgw.js → cli-8snvgs5r.js} +38 -13
- package/dist/{cli-rhw40jgb.js → cli-bjb3z51z.js} +2 -2
- package/dist/{cli-xtjhfmpc.js → cli-c60vetmm.js} +1 -1
- package/dist/{cli-0731hvbw.js → cli-decfb6v8.js} +2 -2
- package/dist/{cli-bhjt02p8.js → cli-drr5v6av.js} +1 -1
- package/dist/{cli-fqzx7gwa.js → cli-dxae2yg7.js} +2 -2
- package/dist/{cli-tqgg40ek.js → cli-e3r2neck.js} +8 -7
- package/dist/{cli-zppwg9p9.js → cli-ekwks2vs.js} +1 -1
- package/dist/{cli-nzxr556s.js → cli-es6va8jk.js} +26 -26
- package/dist/{cli-a47xy8eq.js → cli-faxe6thg.js} +1 -1
- package/dist/cli-gdhpcbhf.js +55 -0
- package/dist/{cli-82jg7fp9.js → cli-he5nxaa8.js} +1 -1
- package/dist/{cli-8veytvbh.js → cli-he9wchme.js} +1 -1
- package/dist/{cli-pywf3eh5.js → cli-hgaazk69.js} +1 -1
- package/dist/{cli-0bxqnvq9.js → cli-hr2e2fft.js} +28 -6
- package/dist/{cli-1ayk1eas.js → cli-k2gzwsj2.js} +135 -26
- package/dist/{cli-xbkdm710.js → cli-kqdg1bsb.js} +3 -3
- package/dist/cli-m2z8jagn.js +12078 -0
- package/dist/{cli-g61qxmge.js → cli-m48g6p95.js} +3 -3
- package/dist/{cli-7wspm5gm.js → cli-nhrvpaj7.js} +20 -4
- package/dist/{cli-afrrppsw.js → cli-nwj0ee2w.js} +2 -2
- package/dist/{cli-3h4nhjn8.js → cli-p5gp8pcx.js} +9 -7
- package/dist/{cli-rm6hqj3z.js → cli-pd7vagab.js} +33 -139
- package/dist/{cli-mjzynrse.js → cli-q0k1w5zd.js} +1 -1
- package/dist/{cli-8hs41zy2.js → cli-rmmmrjkd.js} +1 -1
- package/dist/{cli-kg8hchyg.js → cli-sa02jsqt.js} +1 -1
- package/dist/{cli-1338796d.js → cli-shpbx5ee.js} +142 -98
- package/dist/{cli-82ae98pz.js → cli-svj5090z.js} +2 -2
- package/dist/{cli-bnz7a7qf.js → cli-vvpx3gsx.js} +1 -1
- package/dist/{cli-mmwm7zbf.js → cli-w6dt9ged.js} +1 -1
- package/dist/{cli-wvaawacd.js → cli-waqbxg08.js} +5230 -66
- package/dist/{cli-j3jvnpe8.js → cli-wp9sp1qb.js} +1 -1
- package/dist/{cli-nmbmy64z.js → cli-xbbndvjy.js} +3 -3
- package/dist/{cli-vbyfwh92.js → cli-xds5f77d.js} +2 -2
- package/dist/{cli-7wcyk2s5.js → cli-xnqgpshy.js} +1 -1
- package/dist/{cli-rpm2sgv0.js → cli-xnrwazav.js} +19 -2
- package/dist/{cli-gf5hbmdr.js → cli-yjdgea32.js} +3 -3
- package/dist/{cli-mry0qzym.js → cli-yn4kxs06.js} +123 -10
- package/dist/{cli-hn9hxmz9.js → cli-yx4kfngt.js} +1 -1
- package/dist/{cli-y24sc29k.js → cli-yy3rhsdw.js} +30 -8
- package/dist/{cli-6s7vdcta.js → cli-zbbwez0r.js} +4 -4
- package/dist/{cli-z6jkx23q.js → cli-zm72anhg.js} +9 -9
- package/dist/cli.js +22 -21
- package/dist/{codex-adapter-q6k3gnyg.js → codex-adapter-ayd1fb6h.js} +2 -2
- package/dist/{codex-session-runner-3mwmk3d9.js → codex-session-runner-jesa32xc.js} +2 -2
- package/dist/{commands-evrenjn9.js → commands-hanewgf2.js} +9 -3
- package/dist/{controller-xjp8vswh.js → controller-dt1n3c1q.js} +8 -3
- package/dist/{controllers-connect-pp5jnxvc.js → controllers-connect-xx8x64n5.js} +20 -19
- package/dist/{cursor-adapter-yf47w4t0.js → cursor-adapter-9s4dwwdw.js} +1 -1
- package/dist/{db-zxpw6aht.js → db-3vttcbv4.js} +3 -3
- package/dist/{db-rgfsh91f.js → db-9dv0g1ab.js} +11 -3
- package/dist/{devtools-82fzqra8.js → devtools-qxfgbsqz.js} +1 -1
- package/dist/{dispatcher-smasgws7.js → dispatcher-tx9hd1sv.js} +17 -16
- package/dist/{docker-rrjhaqt9.js → docker-c1a3gqmf.js} +89 -22
- package/dist/{dsh-adapter-ftyy8dnp.js → dsh-adapter-007y4ejs.js} +1 -1
- package/dist/{e2b-qcaj675y.js → e2b-a19d28rz.js} +2 -2
- package/dist/{e2b-w1p0bnpm.js → e2b-we6hs6q5.js} +4 -4
- package/dist/{easter-egg-3d-yyts7p16.js → easter-egg-3d-rgbrqb8s.js} +1 -1
- package/dist/{embedding-space-1z0mbg95.js → embedding-space-ny1e2940.js} +6 -6
- package/dist/{embeddings-ygeg7tm7.js → embeddings-2t386t13.js} +1 -1
- package/dist/{execute-14qhqp4d.js → execute-pksh0ceh.js} +1 -1
- package/dist/{external-channel-ttgrs3sx.js → external-channel-phgmhmd7.js} +2 -2
- package/dist/{fx-adapter-gt6pncj2.js → fx-adapter-fy8rr921.js} +5 -5
- package/dist/{google-generative-ai-yvmdxyxa.js → google-generative-ai-8y3m3k4b.js} +1 -1
- package/dist/{google-vertex-jrtdh9gs.js → google-vertex-w3v98n7b.js} +1 -1
- package/dist/{grok-adapter-sm4m5k4g.js → grok-adapter-q0abvpaj.js} +5 -5
- package/dist/handlers-by3sfxyx.js +32 -0
- package/dist/{heartbeat-qahz4p62.js → heartbeat-kadyek8p.js} +20 -19
- package/dist/{hook-kj3d398m.js → hook-5zs3msf6.js} +2 -2
- package/dist/{hook-4fd5wq5w.js → hook-9k551xnj.js} +2 -2
- package/dist/{http-1ted0j1k.js → http-mhz9vw1f.js} +213 -80
- package/dist/index-4f2pvxae.js +37 -0
- package/dist/index-98gz7tmp.js +30 -0
- package/dist/{index-5rk6rfzr.js → index-fvzke5kx.js} +1 -1
- package/dist/index-k08z2cak.js +33 -0
- package/dist/{index-yqhz88yy.js → index-pxn30mse.js} +8 -8
- package/dist/{index-6rq3r5zb.js → index-q361cg2f.js} +1 -1
- package/dist/{index-7v51awbn.js → index-rvr00sg7.js} +20 -13
- package/dist/{index-q4b917zs.js → index-vdygbq10.js} +1 -1
- package/dist/{keepalive-6cf311t5.js → keepalive-w56f32cg.js} +3 -3
- package/dist/{lead-fjy418cw.js → lead-enq35nqp.js} +31 -30
- package/dist/{maintenance-a4bm1bmt.js → maintenance-7mdgxcea.js} +1 -1
- package/dist/{oauth-refresh-sweep-3h0dfw1k.js → oauth-refresh-sweep-29txy9vk.js} +2 -2
- package/dist/{onboard-myn5hraa.js → onboard-kpsrkarj.js} +4 -4
- package/dist/{opencode-adapter-p2nkb5am.js → opencode-adapter-j25vm3rr.js} +3 -3
- package/dist/{otel-impl-a1a0ef6b.js → otel-impl-6kkct7a8.js} +1 -1
- package/dist/{pi-mono-adapter-rzmpb72a.js → pi-mono-adapter-exhkzhn2.js} +4 -4
- package/dist/{pricing-refresh-83drrnxj.js → pricing-refresh-n2x7jh6k.js} +3 -3
- package/dist/{rbac-roles-s8y6ecb5.js → rbac-roles-81ypx6es.js} +1 -1
- package/dist/{rbac-roles-0fb6e7pj.js → rbac-roles-b5htkjby.js} +2 -2
- package/dist/{render-v2-twmbgrz4.js → render-v2-qn6jmpk4.js} +3 -3
- package/dist/{resume-zfqc36xj.js → resume-wd30wx23.js} +6 -6
- package/dist/{runtime-qwggdpqr.js → runtime-4hsg7h60.js} +1 -1
- package/dist/{session-access-sc9pn9da.js → session-access-5qvfevhk.js} +4 -4
- package/dist/{session-source-backfill-qh07xr8g.js → session-source-backfill-n1czavbr.js} +2 -2
- package/dist/{setup-4fr0m7a1.js → setup-dnb56qy5.js} +3 -3
- package/dist/{virtual-modules-fh4ha15b.js → virtual-modules-76xxv9fs.js} +2 -2
- package/dist/{work-objects-0wr8zxct.js → work-objects-4n21tfkm.js} +20 -19
- package/dist/{worker-follow-up-ktyyazyp.js → worker-follow-up-77hvzxww.js} +17 -16
- package/dist/{worker-0c5tyzqe.js → worker-gg1576j3.js} +31 -30
- package/openapi.json +93 -1
- package/package.json +8 -7
- package/src/be/db/script-runs.ts +23 -0
- package/src/be/db/tasks/read.ts +122 -3
- package/src/be/db-client.ts +8 -5
- package/src/be/db.ts +15 -4
- package/src/be/memory/providers/sqlite-store.ts +49 -12
- package/src/be/migrations/260_runtime_pool_cache.sql +20 -0
- package/src/be/migrations/262_session_visibility_indexes.sql +18 -0
- package/src/be/migrations/263_task_list_filter_index.sql +11 -0
- package/src/be/migrations/264_slack_thread_cover_index.sql +9 -0
- package/src/be/migrations/266_script_runs_source_task.sql +11 -0
- package/src/be/runtime-pools/cache-cleanup.ts +109 -0
- package/src/be/runtime-pools/db.ts +65 -6
- package/src/be/runtime-pools/remote-controller.ts +5 -0
- package/src/be/runtime-pools/supervisor.ts +33 -2
- package/src/be/scripts/typecheck.ts +22 -0
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +5 -1
- package/src/commands/controller.ts +4 -0
- package/src/commands/entrypoint-api-readiness.ts +74 -0
- package/src/http/index.ts +1 -1
- package/src/http/list-total-cache.ts +14 -7
- package/src/http/memory.ts +38 -9
- package/src/http/script-runs.ts +174 -5
- package/src/http/status.ts +10 -2
- package/src/http/tasks.ts +4 -2
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/fx-adapter.ts +1 -1
- package/src/runtime-providers/controller-protocol.ts +14 -1
- package/src/runtime-providers/controller.ts +5 -0
- package/src/runtime-providers/docker.ts +108 -23
- package/src/runtime-providers/types.ts +16 -0
- package/src/script-workflows/workflow-ctx.ts +8 -0
- package/src/scripts-runtime/types/stdlib.d.ts +22 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +22 -0
- package/src/server-user.ts +1 -0
- package/src/slack/commands.ts +5 -1
- package/src/slack/handlers.ts +37 -2
- package/src/slack/left-threads.ts +27 -4
- package/src/tasks/follow-up-redelegation-guard.ts +155 -0
- package/src/tests/claude-artifacts-read-script.test.ts +209 -0
- package/src/tests/db-client.test.ts +21 -0
- package/src/tests/db-task-read-contract.test.ts +8 -2
- package/src/tests/db-task-write-contract.test.ts +5 -1
- package/src/tests/entrypoint-api-readiness.test.ts +416 -210
- package/src/tests/explicit-model-validation.test.ts +14 -8
- package/src/tests/follow-up-redelivery-guard.test.ts +432 -202
- package/src/tests/list-total-cache.test.ts +27 -2
- package/src/tests/memory-fts-populate.test.ts +11 -9
- package/src/tests/memory-retrieval-purge.test.ts +80 -0
- package/src/tests/migration-260-runtime-pool-cache.test.ts +118 -0
- package/src/tests/query-audit-plans.test.ts +27 -0
- package/src/tests/runtime-cache-cleanup.test.ts +367 -0
- package/src/tests/runtime-e2b-pools.test.ts +41 -0
- package/src/tests/runtime-pools-db.test.ts +98 -0
- package/src/tests/runtime-pools-http.test.ts +29 -0
- package/src/tests/runtime-providers-docker.test.ts +253 -0
- package/src/tests/runtime-remote-controller.test.ts +66 -2
- package/src/tests/runtime-supervisor.test.ts +66 -2
- package/src/tests/script-connections.test.ts +11 -0
- package/src/tests/script-runs-http.test.ts +684 -2
- package/src/tests/seed-scripts.test.ts +26 -0
- package/src/tests/slack-leave-thread.test.ts +145 -5
- package/src/tests/slack-render-v2-query-plans.test.ts +24 -2
- package/src/tests/status.test.ts +11 -0
- package/src/tests/tasks-readable-row.test.ts +155 -0
- package/src/tools/send-task.ts +35 -47
- package/src/tools/slack-leave-thread.ts +1 -1
- package/src/tools/task-dedup.ts +45 -21
- package/src/types.ts +38 -0
- package/templates/skills/composio-google-docs/content.md +10 -0
- package/templates/skills/script-workflows/content.md +19 -1
- package/templates/skills/swarm-docs/content.md +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +2 -0
- package/templates/skills/swarm-scripts/content.md +2 -0
- package/dist/cli-dznrgaep.js +0 -30
- package/dist/cli-w3hcrxht.js +0 -6985
- package/dist/handlers-vwmba1m2.js +0 -31
- package/dist/index-5r71rf98.js +0 -32
- package/dist/index-anmwxs87.js +0 -29
- package/dist/index-spp0ra5c.js +0 -36
|
@@ -217,6 +217,32 @@ describe("seed-scripts catalog", () => {
|
|
|
217
217
|
).toHaveLength(200);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
test.each([
|
|
221
|
+
["Incomplete response returned, reason: max_output_tokens", "output-limit"],
|
|
222
|
+
["Stopped: max_tokens", "output-limit"],
|
|
223
|
+
["Maximum output length exceeded", "output-limit"],
|
|
224
|
+
["Output token limit reached", "output-limit"],
|
|
225
|
+
["401 Unauthorized", "auth/credentials"],
|
|
226
|
+
["invalid token", "auth/credentials"],
|
|
227
|
+
["Resource not accessible by personal access token", "auth/credentials"],
|
|
228
|
+
["Processed 100 tokens", "other"],
|
|
229
|
+
["max_input_tokens exceeded", "other"],
|
|
230
|
+
])("task-failure-audit classifies %s as %s", async (failureReason, expectedKey) => {
|
|
231
|
+
const result = await taskFailureAudit(
|
|
232
|
+
{ publishPage: false },
|
|
233
|
+
{
|
|
234
|
+
swarm: {
|
|
235
|
+
async task_list() {
|
|
236
|
+
return { data: { tasks: [{ id: "failed-task", failureReason }] } };
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(result.groups).toHaveLength(1);
|
|
243
|
+
expect(result.groups[0].key).toBe(expectedKey);
|
|
244
|
+
});
|
|
245
|
+
|
|
220
246
|
test("task-failure-audit projects failure reasons into schedule-group samples", async () => {
|
|
221
247
|
let projectionArgs: Record<string, unknown> | undefined;
|
|
222
248
|
const result = await taskFailureAudit(
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
createTaskExtended,
|
|
10
10
|
createWorkflow,
|
|
11
11
|
deleteSwarmConfig,
|
|
12
|
+
getKv,
|
|
12
13
|
getSlackTasksInThread,
|
|
13
14
|
initDb,
|
|
14
15
|
updateWorkflow,
|
|
@@ -19,9 +20,14 @@ import { invokeToolInProcess, resetBridgeServerForTesting } from "../http/mcp-br
|
|
|
19
20
|
import { handleTasks } from "../http/tasks";
|
|
20
21
|
import { reservedNamespaceError } from "../kv-reserved-namespaces";
|
|
21
22
|
import { createServer as createMcpServer } from "../server";
|
|
23
|
+
import { registerCommandHandler } from "../slack/commands";
|
|
22
24
|
import { registerMessageHandler, resetSlackHandlerCachesForTesting } from "../slack/handlers";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
+
import {
|
|
26
|
+
isSlackThreadLeft,
|
|
27
|
+
SLACK_LEFT_THREAD_CONFIRMATION,
|
|
28
|
+
SLACK_LEFT_THREAD_KV_NAMESPACE,
|
|
29
|
+
} from "../slack/left-threads";
|
|
30
|
+
import { bufferThreadMessage, getBufferMessageCount, instantFlush } from "../slack/thread-buffer";
|
|
25
31
|
import { slackContextKey } from "../tasks/context-key";
|
|
26
32
|
import { registerSlackLeaveThreadTool } from "../tools/slack-leave-thread";
|
|
27
33
|
import type { Agent, AgentTask } from "../types";
|
|
@@ -45,6 +51,8 @@ const previousEnv = {
|
|
|
45
51
|
|
|
46
52
|
type Handler = (args: Record<string, unknown>) => Promise<void>;
|
|
47
53
|
const handlers = new Map<string, Handler>();
|
|
54
|
+
const commands = new Map<string, Handler>();
|
|
55
|
+
const ephemerals: Array<Record<string, unknown>> = [];
|
|
48
56
|
|
|
49
57
|
const client = {
|
|
50
58
|
auth: { test: async () => ({ user_id: BOT_USER_ID, bot_id: "B_LEAVE_BOT" }) },
|
|
@@ -52,7 +60,13 @@ const client = {
|
|
|
52
60
|
info: async () => ({ ok: true, channel: {} }),
|
|
53
61
|
replies: async () => ({ ok: true, messages: [] }),
|
|
54
62
|
},
|
|
55
|
-
chat: {
|
|
63
|
+
chat: {
|
|
64
|
+
postMessage: async () => ({ ok: true, ts: "1.1" }),
|
|
65
|
+
postEphemeral: async (args: Record<string, unknown>) => {
|
|
66
|
+
ephemerals.push(args);
|
|
67
|
+
return { ok: true };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
56
70
|
reactions: { add: async () => ({ ok: true }) },
|
|
57
71
|
users: { info: async () => ({ ok: true, user: { profile: {} } }) },
|
|
58
72
|
};
|
|
@@ -99,10 +113,10 @@ let lead: Agent;
|
|
|
99
113
|
let worker: Agent;
|
|
100
114
|
let otherWorker: Agent;
|
|
101
115
|
|
|
102
|
-
async function seedThread(channel: string, threadTs: string) {
|
|
116
|
+
async function seedThread(channel: string, threadTs: string, opts: { unassigned?: boolean } = {}) {
|
|
103
117
|
// As the Slack handlers create it: only Slack ingress writes slackTriggerMessageTs.
|
|
104
118
|
return createTaskExtended("original ask", {
|
|
105
|
-
agentId: worker.id,
|
|
119
|
+
agentId: opts.unassigned ? undefined : worker.id,
|
|
106
120
|
source: "slack",
|
|
107
121
|
slackChannelId: channel,
|
|
108
122
|
slackThreadTs: threadTs,
|
|
@@ -158,6 +172,11 @@ beforeAll(async () => {
|
|
|
158
172
|
handlers.set(type, handler);
|
|
159
173
|
},
|
|
160
174
|
} as never);
|
|
175
|
+
registerCommandHandler({
|
|
176
|
+
command: (name: string, handler: Handler) => {
|
|
177
|
+
commands.set(name, handler);
|
|
178
|
+
},
|
|
179
|
+
} as never);
|
|
161
180
|
|
|
162
181
|
routeServer = createServer(async (req, res) => {
|
|
163
182
|
setRequestAuth(req, { kind: "operator", fingerprint: "test-key" });
|
|
@@ -192,6 +211,7 @@ afterAll(async () => {
|
|
|
192
211
|
|
|
193
212
|
beforeEach(() => {
|
|
194
213
|
resetSlackHandlerCachesForTesting();
|
|
214
|
+
ephemerals.length = 0;
|
|
195
215
|
});
|
|
196
216
|
|
|
197
217
|
describe("slack-leave-thread", () => {
|
|
@@ -429,3 +449,123 @@ describe("SLACK_LEAVE_THREAD_DISABLE", () => {
|
|
|
429
449
|
}
|
|
430
450
|
});
|
|
431
451
|
});
|
|
452
|
+
|
|
453
|
+
describe("!leave", () => {
|
|
454
|
+
test("leaves the thread in code: no task, no workflow event, an ephemeral confirmation", async () => {
|
|
455
|
+
const emitted: unknown[] = [];
|
|
456
|
+
const listener = (payload: unknown) => emitted.push(payload);
|
|
457
|
+
workflowEventBus.on("slack.message", listener);
|
|
458
|
+
try {
|
|
459
|
+
await seedThread("C_BANG_A", "3000.0001");
|
|
460
|
+
expect(await sendThreadReply("C_BANG_A", "3000.0001", "!leave")).toHaveLength(1);
|
|
461
|
+
expect(emitted).toHaveLength(0);
|
|
462
|
+
} finally {
|
|
463
|
+
workflowEventBus.off("slack.message", listener);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
expect(await isSlackThreadLeft("C_BANG_A", "3000.0001")).toBe(true);
|
|
467
|
+
const entry = await getKv(SLACK_LEFT_THREAD_KV_NAMESPACE, "C_BANG_A:3000.0001");
|
|
468
|
+
expect(entry?.value).toMatchObject({ slackUserId: HUMAN });
|
|
469
|
+
expect(entry?.value).not.toHaveProperty("agentId");
|
|
470
|
+
expect(ephemerals).toEqual([
|
|
471
|
+
{
|
|
472
|
+
channel: "C_BANG_A",
|
|
473
|
+
user: HUMAN,
|
|
474
|
+
thread_ts: "3000.0001",
|
|
475
|
+
text: SLACK_LEFT_THREAD_CONFIRMATION,
|
|
476
|
+
},
|
|
477
|
+
]);
|
|
478
|
+
|
|
479
|
+
expect(await sendThreadReply("C_BANG_A", "3000.0001", "chatting among ourselves")).toHaveLength(
|
|
480
|
+
1,
|
|
481
|
+
);
|
|
482
|
+
expect(
|
|
483
|
+
await sendThreadReply("C_BANG_A", "3000.0001", `<@${BOT_USER_ID}> back please`),
|
|
484
|
+
).toHaveLength(2);
|
|
485
|
+
expect(await isSlackThreadLeft("C_BANG_A", "3000.0001")).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("an @mention before !leave leaves instead of rejoining, also in a left thread", async () => {
|
|
489
|
+
await seedThread("C_BANG_B", "3100.0001");
|
|
490
|
+
await sendThreadReply("C_BANG_B", "3100.0001", "!leave");
|
|
491
|
+
expect(await sendThreadReply("C_BANG_B", "3100.0001", `<@${BOT_USER_ID}> !leave`)).toHaveLength(
|
|
492
|
+
1,
|
|
493
|
+
);
|
|
494
|
+
expect(await isSlackThreadLeft("C_BANG_B", "3100.0001")).toBe(true);
|
|
495
|
+
expect(ephemerals).toHaveLength(2);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("with ADDITIVE_SLACK the !leave message is never buffered", async () => {
|
|
499
|
+
process.env.ADDITIVE_SLACK = "true";
|
|
500
|
+
try {
|
|
501
|
+
await seedThread("C_BANG_C", "3200.0001");
|
|
502
|
+
await sendThreadReply("C_BANG_C", "3200.0001", "!leave");
|
|
503
|
+
expect(getBufferMessageCount("C_BANG_C:3200.0001")).toBe(0);
|
|
504
|
+
expect(await isSlackThreadLeft("C_BANG_C", "3200.0001")).toBe(true);
|
|
505
|
+
} finally {
|
|
506
|
+
process.env.ADDITIVE_SLACK = "false";
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test.each([
|
|
511
|
+
["!leave", "3600.0001"],
|
|
512
|
+
[`<@${BOT_USER_ID}> !leave`, "3600.0002"],
|
|
513
|
+
])("leaves a human-rooted thread whose only task is unassigned: %s", async (text, threadTs) => {
|
|
514
|
+
const emitted: unknown[] = [];
|
|
515
|
+
const listener = (payload: unknown) => emitted.push(payload);
|
|
516
|
+
workflowEventBus.on("slack.message", listener);
|
|
517
|
+
try {
|
|
518
|
+
const seeded = await seedThread("C_BANG_G", threadTs, { unassigned: true });
|
|
519
|
+
expect(seeded.agentId).toBeNull();
|
|
520
|
+
expect(await sendThreadReply("C_BANG_G", threadTs, text)).toHaveLength(1);
|
|
521
|
+
expect(emitted).toHaveLength(0);
|
|
522
|
+
} finally {
|
|
523
|
+
workflowEventBus.off("slack.message", listener);
|
|
524
|
+
}
|
|
525
|
+
expect(await isSlackThreadLeft("C_BANG_G", threadTs)).toBe(true);
|
|
526
|
+
expect(ephemerals).toHaveLength(1);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("a task in another thread of the channel does not make !leave a command", async () => {
|
|
530
|
+
await seedThread("C_BANG_H", "3700.0001", { unassigned: true });
|
|
531
|
+
await sendThreadReply("C_BANG_H", "3700.0002", "!leave");
|
|
532
|
+
expect(await isSlackThreadLeft("C_BANG_H", "3700.0002")).toBe(false);
|
|
533
|
+
expect(ephemerals).toHaveLength(0);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("is not a command outside a swarm thread, when addressed elsewhere, or with extra text", async () => {
|
|
537
|
+
await sendThreadReply("C_BANG_D", "3300.0001", "!leave");
|
|
538
|
+
expect(await isSlackThreadLeft("C_BANG_D", "3300.0001")).toBe(false);
|
|
539
|
+
|
|
540
|
+
await seedThread("C_BANG_E", "3400.0001");
|
|
541
|
+
await sendThreadReply("C_BANG_E", "3400.0001", "<@U_SOMEONE_ELSE> !leave");
|
|
542
|
+
await sendThreadReply("C_BANG_E", "3400.0001", "!leave it to me");
|
|
543
|
+
expect(await isSlackThreadLeft("C_BANG_E", "3400.0001")).toBe(false);
|
|
544
|
+
expect(ephemerals).toHaveLength(0);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("SLACK_LEAVE_THREAD_DISABLE makes !leave an ordinary message and hides it from help", async () => {
|
|
548
|
+
async function helpText(): Promise<string> {
|
|
549
|
+
let text = "";
|
|
550
|
+
await commands.get("/agent-swarm-help")!({
|
|
551
|
+
ack: async () => {},
|
|
552
|
+
respond: async (reply: { blocks: Array<{ text: { text: string } }> }) => {
|
|
553
|
+
text = reply.blocks.map((b) => b.text.text).join("\n");
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
return text;
|
|
557
|
+
}
|
|
558
|
+
expect(await helpText()).toContain("`!leave` in a thread");
|
|
559
|
+
|
|
560
|
+
process.env.SLACK_LEAVE_THREAD_DISABLE = "true";
|
|
561
|
+
try {
|
|
562
|
+
await seedThread("C_BANG_F", "3500.0001");
|
|
563
|
+
expect(await sendThreadReply("C_BANG_F", "3500.0001", "!leave")).toHaveLength(2);
|
|
564
|
+
expect(await isSlackThreadLeft("C_BANG_F", "3500.0001")).toBe(false);
|
|
565
|
+
expect(ephemerals).toHaveLength(0);
|
|
566
|
+
expect(await helpText()).not.toContain("!leave");
|
|
567
|
+
} finally {
|
|
568
|
+
delete process.env.SLACK_LEAVE_THREAD_DISABLE;
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
1
2
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
3
|
import {
|
|
3
4
|
closeDb,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
getDb,
|
|
7
8
|
getDbClient,
|
|
8
9
|
getResolvableDeferralOutcomes,
|
|
10
|
+
getSlackTreeMessages,
|
|
9
11
|
initDb,
|
|
10
12
|
recordSlackMessage,
|
|
11
13
|
} from "../be/db";
|
|
@@ -93,12 +95,32 @@ describe("slack render-v2 watcher queries", () => {
|
|
|
93
95
|
closeDb();
|
|
94
96
|
});
|
|
95
97
|
|
|
98
|
+
test("the tree sweep tests thread tasks in the covering index, not task rows", async () => {
|
|
99
|
+
const sql = await capturedSql(() => getSlackTreeMessages());
|
|
100
|
+
const detail = plan(sql);
|
|
101
|
+
const uses = "SEARCH task USING COVERING INDEX idx_agent_tasks_slack_thread_cover";
|
|
102
|
+
expect(detail.split(uses).length - 1).toBe(3);
|
|
103
|
+
// Control: without migration 264 the same tests read the task rows.
|
|
104
|
+
const copy = Database.deserialize(getDb().serialize());
|
|
105
|
+
try {
|
|
106
|
+
copy.run("DROP INDEX idx_agent_tasks_slack_thread_cover");
|
|
107
|
+
const reverted = copy
|
|
108
|
+
.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`)
|
|
109
|
+
.all()
|
|
110
|
+
.map((row) => row.detail)
|
|
111
|
+
.join("\n");
|
|
112
|
+
expect(reverted).not.toContain(uses);
|
|
113
|
+
} finally {
|
|
114
|
+
copy.close();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
96
118
|
test("deferral resolution drives from the chain, not from every task", async () => {
|
|
97
119
|
const sql = await capturedSql(() => getResolvableDeferralOutcomes());
|
|
98
120
|
const detail = plan(sql, [50]);
|
|
99
|
-
//
|
|
121
|
+
// Any parentTaskId-led index is a seek on the chain's children.
|
|
100
122
|
expect(detail).toMatch(
|
|
101
|
-
/SEARCH wake USING INDEX idx_agent_tasks_parentTaskId(_source)? \(parentTaskId=\?\)/,
|
|
123
|
+
/SEARCH wake USING INDEX idx_agent_tasks_parentTaskId(_source|_id)? \(parentTaskId=\?\)/,
|
|
102
124
|
);
|
|
103
125
|
expect(detail).not.toContain("idx_agent_tasks_taskType");
|
|
104
126
|
expect(detail).not.toContain("idx_agent_tasks_status_last_updated");
|
package/src/tests/status.test.ts
CHANGED
|
@@ -87,6 +87,8 @@ const ENV_KEYS_TO_RESET = [
|
|
|
87
87
|
"SWARM_BRAND_COLOR",
|
|
88
88
|
"SWARM_MARKETING_URL",
|
|
89
89
|
"SWARM_HIDE_CLOUD_PROMO",
|
|
90
|
+
"APP_URL",
|
|
91
|
+
"DASHBOARD_URL",
|
|
90
92
|
"HARNESS_PROVIDER",
|
|
91
93
|
"ANTHROPIC_API_KEY",
|
|
92
94
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
@@ -191,6 +193,7 @@ describe("buildStatusPayload — identity", () => {
|
|
|
191
193
|
marketing_url: null,
|
|
192
194
|
hide_cloud_promo: false,
|
|
193
195
|
org_id: null,
|
|
196
|
+
dashboard_url: null,
|
|
194
197
|
});
|
|
195
198
|
});
|
|
196
199
|
|
|
@@ -212,9 +215,17 @@ describe("buildStatusPayload — identity", () => {
|
|
|
212
215
|
marketing_url: "https://swarm.acme.example",
|
|
213
216
|
hide_cloud_promo: true,
|
|
214
217
|
org_id: "org_acme_123",
|
|
218
|
+
dashboard_url: null,
|
|
215
219
|
});
|
|
216
220
|
});
|
|
217
221
|
|
|
222
|
+
test("reports the configured dashboard URL: the first APP_URL entry, else DASHBOARD_URL", async () => {
|
|
223
|
+
process.env.DASHBOARD_URL = "https://old.example.com";
|
|
224
|
+
expect((await buildStatusPayload()).identity.dashboard_url).toBe("https://old.example.com");
|
|
225
|
+
process.env.APP_URL = "https://swarm.example.com/, https://other.example.com";
|
|
226
|
+
expect((await buildStatusPayload()).identity.dashboard_url).toBe("https://swarm.example.com");
|
|
227
|
+
});
|
|
228
|
+
|
|
218
229
|
test("treats SWARM_CLOUD=1 the same as 'true'", async () => {
|
|
219
230
|
process.env.SWARM_CLOUD = "1";
|
|
220
231
|
const payload = await buildStatusPayload();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, getAllTasks, getDb, getDbClient, initDb } from "../be/db";
|
|
4
|
+
import { readableSessionCondition } from "../be/db/tasks/read";
|
|
5
|
+
|
|
6
|
+
const TEST_DB_PATH = "./test-tasks-readable-row.sqlite";
|
|
7
|
+
const AT = "2026-10-01T00:00:00.000Z";
|
|
8
|
+
|
|
9
|
+
async function removeDbFiles(): Promise<void> {
|
|
10
|
+
for (const suffix of ["", "-wal", "-shm"])
|
|
11
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => undefined);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function task(
|
|
15
|
+
id: string,
|
|
16
|
+
opts: { parent?: string; owner?: string; scheduleId?: string } = {},
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
await getDbClient().run(
|
|
19
|
+
`INSERT INTO agent_tasks (id, task, status, source, parentTaskId, requestedByUserId, scheduleId,
|
|
20
|
+
createdAt, lastUpdatedAt)
|
|
21
|
+
VALUES (?, ?, 'completed', 'api', ?, ?, ?, ?, ?)`,
|
|
22
|
+
[id, `task ${id}`, opts.parent ?? null, opts.owner ?? null, opts.scheduleId ?? null, AT, AT],
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("readableSessionRowCondition", () => {
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
await removeDbFiles();
|
|
29
|
+
initDb(TEST_DB_PATH);
|
|
30
|
+
for (const id of ["u1", "u2", "u3"]) {
|
|
31
|
+
await getDbClient().run(
|
|
32
|
+
"INSERT INTO users (id, name, role, status, createdAt, lastUpdatedAt) VALUES (?, ?, 'member', 'active', ?, ?)",
|
|
33
|
+
[id, id, AT, AT],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
// u1's session, three deep.
|
|
37
|
+
await task("a", { owner: "u1" });
|
|
38
|
+
await task("a1", { parent: "a", owner: "u1" });
|
|
39
|
+
await task("a2", { parent: "a1" });
|
|
40
|
+
// u2's session, with a child that names u1.
|
|
41
|
+
await task("b", { owner: "u2" });
|
|
42
|
+
await task("b1", { parent: "b", owner: "u1" });
|
|
43
|
+
// u2's schedule run: everyone reads it.
|
|
44
|
+
await task("c", { owner: "u2", scheduleId: "schedule-1" });
|
|
45
|
+
await task("c1", { parent: "c" });
|
|
46
|
+
// u2's session shared with u1, and one shared with everyone.
|
|
47
|
+
await task("d", { owner: "u2" });
|
|
48
|
+
await task("d1", { parent: "d" });
|
|
49
|
+
await task("e", { owner: "u2" });
|
|
50
|
+
await getDbClient().run(
|
|
51
|
+
`INSERT INTO session_shares (id, rootTaskId, userId, access, createdAt)
|
|
52
|
+
VALUES ('share-d', 'd', 'u1', 'read', ?), ('share-e', 'e', NULL, 'read', ?)`,
|
|
53
|
+
[AT, AT],
|
|
54
|
+
);
|
|
55
|
+
// A revoked share hides again.
|
|
56
|
+
await task("f", { owner: "u2" });
|
|
57
|
+
await getDbClient().run(
|
|
58
|
+
`INSERT INTO session_shares (id, rootTaskId, userId, access, createdAt, revokedAt)
|
|
59
|
+
VALUES ('share-f', 'f', 'u1', 'read', ?, ?)`,
|
|
60
|
+
[AT, AT],
|
|
61
|
+
);
|
|
62
|
+
// A session nobody owns.
|
|
63
|
+
await task("g");
|
|
64
|
+
await task("g1", { parent: "g", owner: "u2" });
|
|
65
|
+
// Orphan subtrees: the top's parent is gone, so the top is the root.
|
|
66
|
+
await task("h", { owner: "u2" });
|
|
67
|
+
await task("h1", { parent: "h" });
|
|
68
|
+
await task("h2", { parent: "h1", owner: "u1" });
|
|
69
|
+
await task("i", { owner: "u1" });
|
|
70
|
+
await task("i1", { parent: "i", owner: "u2" });
|
|
71
|
+
await task("i2", { parent: "i1" });
|
|
72
|
+
// Chains deeper than the walk's hop limit fall back to the set form.
|
|
73
|
+
for (const [root, owner] of [
|
|
74
|
+
["deep-u1", "u1"],
|
|
75
|
+
["deep-u2", "u2"],
|
|
76
|
+
] as const) {
|
|
77
|
+
await task(root, { owner });
|
|
78
|
+
let parent: string = root;
|
|
79
|
+
for (let i = 1; i <= 70; i++) {
|
|
80
|
+
await task(`${root}-${i}`, { parent });
|
|
81
|
+
parent = `${root}-${i}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
getDb().run("PRAGMA foreign_keys = OFF");
|
|
85
|
+
getDb().run("DELETE FROM agent_tasks WHERE id IN ('h', 'i')");
|
|
86
|
+
getDb().run("PRAGMA foreign_keys = ON");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
closeDb();
|
|
91
|
+
await removeDbFiles();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
for (const userId of ["u1", "u2", "u3", null]) {
|
|
95
|
+
test(`the page shows the rows the set form shows, for ${userId ?? "a guest"}`, async () => {
|
|
96
|
+
const set = readableSessionCondition(userId);
|
|
97
|
+
const expected = (
|
|
98
|
+
await getDbClient().query<{ id: string }>(
|
|
99
|
+
`SELECT id FROM agent_tasks WHERE ${set.sql} ORDER BY id`,
|
|
100
|
+
set.params,
|
|
101
|
+
)
|
|
102
|
+
).map((row) => row.id);
|
|
103
|
+
const page = await getAllTasks({ readableBy: { userId }, limit: 1000 });
|
|
104
|
+
expect(page.map((t) => t.id).sort()).toEqual(expected);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("u1 sees exactly the expected sessions", async () => {
|
|
109
|
+
const page = await getAllTasks({ readableBy: { userId: "u1" }, limit: 1000 });
|
|
110
|
+
const deep = Array.from({ length: 70 }, (_, i) => `deep-u1-${i + 1}`);
|
|
111
|
+
expect(page.map((t) => t.id).sort()).toEqual(
|
|
112
|
+
[
|
|
113
|
+
"a",
|
|
114
|
+
"a1",
|
|
115
|
+
"a2",
|
|
116
|
+
"c",
|
|
117
|
+
"c1",
|
|
118
|
+
"d",
|
|
119
|
+
"d1",
|
|
120
|
+
"e",
|
|
121
|
+
"g",
|
|
122
|
+
"g1",
|
|
123
|
+
"h1",
|
|
124
|
+
"h2",
|
|
125
|
+
"deep-u1",
|
|
126
|
+
...deep,
|
|
127
|
+
].sort(),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the page walks up from each row it reads", async () => {
|
|
132
|
+
const query = spyOn(getDbClient(), "query");
|
|
133
|
+
let sql = "";
|
|
134
|
+
let params: unknown[] = [];
|
|
135
|
+
try {
|
|
136
|
+
await getAllTasks({ status: "completed", readableBy: { userId: "u1" } });
|
|
137
|
+
[sql, params = []] = query.mock.calls.find(([s]) => s.includes("WITH page AS"))! as [
|
|
138
|
+
string,
|
|
139
|
+
unknown[]?,
|
|
140
|
+
];
|
|
141
|
+
} finally {
|
|
142
|
+
query.mockRestore();
|
|
143
|
+
}
|
|
144
|
+
const plan = getDb()
|
|
145
|
+
.query<{ detail: string }, any[]>(`EXPLAIN QUERY PLAN ${sql}`)
|
|
146
|
+
.all(...(params as any[]))
|
|
147
|
+
.map((row) => row.detail)
|
|
148
|
+
.join("\n");
|
|
149
|
+
// The walk is a correlated lookup per row. The set form appears only as
|
|
150
|
+
// the fallback branch, which SQLite builds when a walk finds no root.
|
|
151
|
+
expect(plan).toContain("CORRELATED SCALAR SUBQUERY");
|
|
152
|
+
expect(plan).toContain("SEARCH u USING INDEX sqlite_autoindex_agent_tasks_1 (id=?)");
|
|
153
|
+
expect(plan).toContain("SEARCH p USING INDEX sqlite_autoindex_agent_tasks_1 (id=?)");
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/tools/send-task.ts
CHANGED
|
@@ -5,9 +5,7 @@ import { resolveTaskAuditUserId } from "@/be/audit-user";
|
|
|
5
5
|
import {
|
|
6
6
|
createTaskExtended,
|
|
7
7
|
extensionAgentAssignmentError,
|
|
8
|
-
findCompletedTaskInThread,
|
|
9
8
|
findExistingLinearTrackerContextWork,
|
|
10
|
-
findRecentCancelledTaskInThread,
|
|
11
9
|
getActiveTaskCount,
|
|
12
10
|
getAgentById,
|
|
13
11
|
getDbClient,
|
|
@@ -23,6 +21,10 @@ import { repointTrackerSyncBySwarmId } from "@/be/db-queries/tracker";
|
|
|
23
21
|
import { explicitModelErrorForAgent } from "@/be/model-validation";
|
|
24
22
|
import { applyPreTaskCreate } from "@/extensions/apply-task-create";
|
|
25
23
|
import { can } from "@/rbac";
|
|
24
|
+
import {
|
|
25
|
+
evaluateFollowUpRedelegation,
|
|
26
|
+
followUpThreadForSourceTask,
|
|
27
|
+
} from "@/tasks/follow-up-redelegation-guard";
|
|
26
28
|
import { checkSlackRoutingCoherence } from "@/tasks/slack-routing";
|
|
27
29
|
import { findDuplicateTask } from "@/tools/task-dedup";
|
|
28
30
|
import { ownerCtx, type ToolCtx } from "@/tools/task-tool-ctx";
|
|
@@ -163,7 +165,13 @@ export const sendTaskInputSchema = z
|
|
|
163
165
|
.boolean()
|
|
164
166
|
.default(false)
|
|
165
167
|
.describe(
|
|
166
|
-
"If true, skip duplicate detection and create the task even if a similar one exists.",
|
|
168
|
+
"If true, skip duplicate detection and create the task even if a similar one exists. Does not bypass the follow-up re-delegation guard; see allowRedelegation.",
|
|
169
|
+
),
|
|
170
|
+
allowRedelegation: z
|
|
171
|
+
.boolean()
|
|
172
|
+
.default(false)
|
|
173
|
+
.describe(
|
|
174
|
+
"Only matters when you call send-task from a follow-up task in a Slack thread. Such a dispatch is blocked when it repeats a task already completed in that thread (same-work rule, unless a task there was cancelled or failed after that completion), or when you already dispatched 10 tasks into the thread in the last 6 hours (loop-cap rule). Set true to dispatch anyway; the override is logged. Use it only for deliberate re-runs or long chains, never to retry a block you have not read.",
|
|
167
175
|
),
|
|
168
176
|
slackChannelId: z
|
|
169
177
|
.string()
|
|
@@ -292,6 +300,7 @@ export async function sendTaskHandler(
|
|
|
292
300
|
effort,
|
|
293
301
|
profile,
|
|
294
302
|
allowDuplicate,
|
|
303
|
+
allowRedelegation,
|
|
295
304
|
slackChannelId,
|
|
296
305
|
slackThreadTs,
|
|
297
306
|
slackUserId,
|
|
@@ -316,6 +325,7 @@ export async function sendTaskHandler(
|
|
|
316
325
|
const requestedLeadOnly = ctx.kind === "user" ? false : leadOnly;
|
|
317
326
|
const requestedCapabilities = ctx.kind === "user" ? undefined : requiredCapabilities;
|
|
318
327
|
const requestedAllowDuplicate = ctx.kind === "user" ? false : allowDuplicate;
|
|
328
|
+
const requestedAllowRedelegation = ctx.kind === "user" ? false : allowRedelegation;
|
|
319
329
|
const requestedOverrideSlackContext = ctx.kind === "user" ? false : overrideSlackContext;
|
|
320
330
|
|
|
321
331
|
// Defense in depth for direct TypeScript callers that bypass MCP schema parsing.
|
|
@@ -572,7 +582,9 @@ export async function sendTaskHandler(
|
|
|
572
582
|
// Every read in this handler releases the FIFO lock, so two concurrent
|
|
573
583
|
// send-task calls with the same creator and text otherwise both pass the
|
|
574
584
|
// guards and both create a task (no UNIQUE index arbitrates them).
|
|
575
|
-
const evaluateDedupGuards = async (
|
|
585
|
+
const evaluateDedupGuards = async (
|
|
586
|
+
logOverride: boolean,
|
|
587
|
+
): Promise<{
|
|
576
588
|
ok: boolean;
|
|
577
589
|
message: string;
|
|
578
590
|
task?: AgentTask;
|
|
@@ -602,59 +614,35 @@ export async function sendTaskHandler(
|
|
|
602
614
|
}
|
|
603
615
|
}
|
|
604
616
|
|
|
605
|
-
// Guard: prevent re-delegation from follow-up tasks
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
// This prevents the cycle: worker completes → follow-up → Lead re-delegates → repeat.
|
|
609
|
-
//
|
|
610
|
-
// Exception: if a MORE RECENT task in the same thread was cancelled (exit 130,
|
|
611
|
-
// status='cancelled', or status='failed' with failureReason containing
|
|
612
|
-
// "cancelled"), bypass the guard. A cancellation means the work was
|
|
613
|
-
// interrupted — re-dispatch is the correct response, not a deduped no-op.
|
|
614
|
-
// Without this bypass, a cancelled worker permanently jams the thread
|
|
615
|
-
// against re-delegation when an earlier completed sibling exists.
|
|
617
|
+
// Guard: prevent re-delegation from follow-up tasks (same-work and
|
|
618
|
+
// loop-cap rules, see src/tasks/follow-up-redelegation-guard.ts).
|
|
619
|
+
// allowRedelegation bypasses both and is logged.
|
|
616
620
|
//
|
|
617
621
|
// NOTE: `taskType === "resume"` (created by createResumeFollowUp on
|
|
618
622
|
// supersede) is intentionally NOT in this guard — a resume IS the legitimate
|
|
619
623
|
// re-dispatch and bypassing the check is correct. Do not add "resume" here.
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
624
|
+
const followUpThread = creatorAgentId ? await followUpThreadForSourceTask(sourceTaskId) : null;
|
|
625
|
+
if (followUpThread && creatorAgentId) {
|
|
626
|
+
const blocked = await evaluateFollowUpRedelegation({
|
|
627
|
+
...followUpThread,
|
|
628
|
+
creatorAgentId,
|
|
629
|
+
taskDescription,
|
|
630
|
+
targetAgentId: taskOptions.agentId ?? taskOptions.offeredTo,
|
|
631
|
+
});
|
|
632
|
+
if (blocked && !requestedAllowRedelegation) {
|
|
633
|
+
return { ok: false, message: blocked.message };
|
|
634
|
+
}
|
|
635
|
+
if (blocked && logOverride) {
|
|
636
|
+
console.log(
|
|
637
|
+
`[send-task] follow-up re-delegation override: rule=${blocked.rule} creatorAgentId=${creatorAgentId} sourceTaskId=${sourceTaskId} thread=${followUpThread.channelId}/${followUpThread.threadTs}`,
|
|
631
638
|
);
|
|
632
|
-
if (recentCompleted) {
|
|
633
|
-
const recentCancelled = await findRecentCancelledTaskInThread(
|
|
634
|
-
sourceTask.slackChannelId,
|
|
635
|
-
sourceTask.slackThreadTs,
|
|
636
|
-
2880,
|
|
637
|
-
);
|
|
638
|
-
const cancelledMoreRecent =
|
|
639
|
-
recentCancelled &&
|
|
640
|
-
new Date(recentCancelled.lastUpdatedAt).getTime() >
|
|
641
|
-
new Date(recentCompleted.lastUpdatedAt).getTime();
|
|
642
|
-
if (!cancelledMoreRecent) {
|
|
643
|
-
return {
|
|
644
|
-
ok: false,
|
|
645
|
-
message: `Blocked: re-delegation from follow-up task in a thread that already has completed work (task ${recentCompleted.id.slice(0, 8)}). The original request was already handled.`,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
// else: fall through — the cancellation is more recent than the
|
|
649
|
-
// completion, so re-delegation is legitimate.
|
|
650
|
-
}
|
|
651
639
|
}
|
|
652
640
|
}
|
|
653
641
|
|
|
654
642
|
return null;
|
|
655
643
|
};
|
|
656
644
|
|
|
657
|
-
const guard = await evaluateDedupGuards();
|
|
645
|
+
const guard = await evaluateDedupGuards(true);
|
|
658
646
|
if (guard) {
|
|
659
647
|
const guardData = {
|
|
660
648
|
yourAgentId: creatorAgentId,
|
|
@@ -669,7 +657,7 @@ export async function sendTaskHandler(
|
|
|
669
657
|
// Authoritative re-check: the reads above are separated from the INSERT by
|
|
670
658
|
// the whole handler, so only a guard inside this transaction can see a
|
|
671
659
|
// concurrent send-task's committed task.
|
|
672
|
-
const raced = await evaluateDedupGuards();
|
|
660
|
+
const raced = await evaluateDedupGuards(false);
|
|
673
661
|
if (raced) return { success: raced.ok, message: raced.message, task: raced.task };
|
|
674
662
|
|
|
675
663
|
// This transaction already checked the tracker key, excluding the caller's
|
|
@@ -94,7 +94,7 @@ export const registerSlackLeaveThreadTool = (server: McpServer) => {
|
|
|
94
94
|
return toolErr("No Slack thread context available.");
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
await leaveSlackThread(slackChannelId, slackThreadTs, agent.id);
|
|
97
|
+
await leaveSlackThread(slackChannelId, slackThreadTs, { agentId: agent.id });
|
|
98
98
|
return toolOk(
|
|
99
99
|
`Left Slack thread ${slackChannelId}/${slackThreadTs}. Messages there are ignored until someone @mentions the bot.`,
|
|
100
100
|
{ data: { channelId: slackChannelId, threadTs: slackThreadTs } },
|