@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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type ConfigStatus,
|
|
5
5
|
type ProvisionRequest,
|
|
6
6
|
RUNTIME_LABELS,
|
|
7
|
+
type RuntimeCacheReference,
|
|
7
8
|
type RuntimeHandle,
|
|
8
9
|
type RuntimeProvider,
|
|
9
10
|
type RuntimeStatus,
|
|
@@ -63,6 +64,22 @@ class DockerApiError extends Error {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function isCacheMountError(error: unknown): boolean {
|
|
68
|
+
return error instanceof DockerApiError && /\b(?:mount\w*|volume\w*)\b/i.test(error.message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type CacheVolume = { Name: string; Labels?: Record<string, string> };
|
|
72
|
+
|
|
73
|
+
function assertCacheOwner(volume: CacheVolume, cache: RuntimeCacheReference): void {
|
|
74
|
+
if (
|
|
75
|
+
volume.Name !== cache.key ||
|
|
76
|
+
volume.Labels?.[RUNTIME_LABELS.owner] !== cache.ownerId ||
|
|
77
|
+
volume.Labels?.[RUNTIME_LABELS.poolId] !== cache.poolId ||
|
|
78
|
+
volume.Labels?.["swarm.cache"] !== "true"
|
|
79
|
+
)
|
|
80
|
+
throw new Error(`Cache volume ${cache.key} is not owned by this swarm and pool`);
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
async function dockerApi<T>(
|
|
67
84
|
target: RuntimeTargetConfig,
|
|
68
85
|
method: string,
|
|
@@ -119,7 +136,12 @@ async function dockerApi<T>(
|
|
|
119
136
|
|
|
120
137
|
export class DockerRuntimeProvider implements RuntimeProvider {
|
|
121
138
|
readonly kind = "docker" as const;
|
|
122
|
-
readonly traits = {
|
|
139
|
+
readonly traits = {
|
|
140
|
+
canProvision: true,
|
|
141
|
+
ephemeralDisk: true,
|
|
142
|
+
supportsPause: false,
|
|
143
|
+
persistentCache: true,
|
|
144
|
+
};
|
|
123
145
|
|
|
124
146
|
checkConfig(target: RuntimeTargetConfig): ConfigStatus {
|
|
125
147
|
try {
|
|
@@ -132,6 +154,29 @@ export class DockerRuntimeProvider implements RuntimeProvider {
|
|
|
132
154
|
|
|
133
155
|
async provision(target: RuntimeTargetConfig, req: ProvisionRequest): Promise<RuntimeHandle> {
|
|
134
156
|
try {
|
|
157
|
+
let cacheWarning: string | undefined;
|
|
158
|
+
let cacheMount: { Type: "volume"; Source: string; Target: string } | undefined;
|
|
159
|
+
if (req.cache) {
|
|
160
|
+
try {
|
|
161
|
+
const volume = await dockerApi<CacheVolume>(target, "POST", "/volumes/create", {
|
|
162
|
+
Name: req.cache.key,
|
|
163
|
+
Labels: {
|
|
164
|
+
[RUNTIME_LABELS.owner]: req.ownerId,
|
|
165
|
+
[RUNTIME_LABELS.poolId]: req.poolId,
|
|
166
|
+
"swarm.cache": "true",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
// Docker reuses an existing name without replacing its labels.
|
|
170
|
+
assertCacheOwner(volume, {
|
|
171
|
+
key: req.cache.key,
|
|
172
|
+
poolId: req.poolId,
|
|
173
|
+
ownerId: req.ownerId,
|
|
174
|
+
});
|
|
175
|
+
cacheMount = { Type: "volume", Source: req.cache.key, Target: req.cache.mountPath };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
cacheWarning = redactWithEnv(scrubSecrets(String(error)), req.env);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
135
180
|
const path = `/containers/create?name=${encodeURIComponent(`swarm-rt-${req.runtimeId}`)}`;
|
|
136
181
|
const body = {
|
|
137
182
|
Image: req.image,
|
|
@@ -154,40 +199,80 @@ export class DockerRuntimeProvider implements RuntimeProvider {
|
|
|
154
199
|
NetworkMode: target.DOCKER_NETWORK,
|
|
155
200
|
Memory: req.resources?.memoryMb ? req.resources.memoryMb * 1024 * 1024 : undefined,
|
|
156
201
|
NanoCpus: req.resources?.cpu ? Math.round(req.resources.cpu * 1e9) : undefined,
|
|
202
|
+
...(cacheMount ? { Mounts: [cacheMount] } : {}),
|
|
157
203
|
},
|
|
158
204
|
};
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
205
|
+
const createAndStart = async (): Promise<string> => {
|
|
206
|
+
let created: { Id: string };
|
|
207
|
+
try {
|
|
208
|
+
created = await dockerApi(target, "POST", path, body);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (
|
|
211
|
+
!(err instanceof DockerApiError) ||
|
|
212
|
+
err.statusCode !== 404 ||
|
|
213
|
+
!err.message.includes("No such image")
|
|
214
|
+
)
|
|
215
|
+
throw err;
|
|
216
|
+
const pullParams = new URLSearchParams({ fromImage: req.image });
|
|
217
|
+
if (
|
|
218
|
+
!req.image.includes("@") &&
|
|
219
|
+
req.image.lastIndexOf(":") <= req.image.lastIndexOf("/")
|
|
220
|
+
) {
|
|
221
|
+
pullParams.set("tag", "latest");
|
|
222
|
+
}
|
|
223
|
+
await dockerApi(target, "POST", `/images/create?${pullParams}`, undefined, true);
|
|
224
|
+
created = await dockerApi(target, "POST", path, body);
|
|
169
225
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
226
|
+
try {
|
|
227
|
+
await dockerApi(target, "POST", `/containers/${created.Id}/start`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
try {
|
|
230
|
+
await dockerApi(target, "DELETE", `/containers/${created.Id}?force=true&v=true`);
|
|
231
|
+
} catch (cleanupError) {
|
|
232
|
+
if (
|
|
233
|
+
cacheMount &&
|
|
234
|
+
isCacheMountError(err) &&
|
|
235
|
+
!(cleanupError instanceof DockerApiError && cleanupError.statusCode === 404)
|
|
236
|
+
)
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${String(err)}; failed-start cleanup failed: ${String(cleanupError)}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
173
242
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
243
|
+
return created.Id;
|
|
244
|
+
};
|
|
245
|
+
let externalId: string;
|
|
177
246
|
try {
|
|
178
|
-
await
|
|
247
|
+
externalId = await createAndStart();
|
|
179
248
|
} catch (err) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
249
|
+
if (!cacheMount || !isCacheMountError(err)) throw err;
|
|
250
|
+
cacheWarning = redactWithEnv(scrubSecrets(String(err)), req.env);
|
|
251
|
+
delete body.HostConfig.Mounts;
|
|
252
|
+
externalId = await createAndStart();
|
|
184
253
|
}
|
|
185
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
kind: "docker",
|
|
256
|
+
externalId,
|
|
257
|
+
...(cacheWarning ? { meta: { cacheWarning } } : {}),
|
|
258
|
+
};
|
|
186
259
|
} catch (err) {
|
|
187
260
|
throw new Error(redactWithEnv(String(err), req.env));
|
|
188
261
|
}
|
|
189
262
|
}
|
|
190
263
|
|
|
264
|
+
async releaseCache(target: RuntimeTargetConfig, cache: RuntimeCacheReference): Promise<void> {
|
|
265
|
+
const path = `/volumes/${encodeURIComponent(cache.key)}`;
|
|
266
|
+
try {
|
|
267
|
+
const volume = await dockerApi<CacheVolume>(target, "GET", path);
|
|
268
|
+
assertCacheOwner(volume, cache);
|
|
269
|
+
// A volume still attached to any container must remain intact (Docker returns 409).
|
|
270
|
+
await dockerApi(target, "DELETE", path);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (!(error instanceof DockerApiError && error.statusCode === 404)) throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
191
276
|
async terminate(target: RuntimeTargetConfig, handle: RuntimeHandle): Promise<void> {
|
|
192
277
|
try {
|
|
193
278
|
// v=true also removes the container's anonymous volumes (the worker image declares
|
|
@@ -19,6 +19,8 @@ export interface RuntimeProviderTraits {
|
|
|
19
19
|
ephemeralDisk: boolean;
|
|
20
20
|
/** Provider can pause and resume a runtime. */
|
|
21
21
|
supportsPause: boolean;
|
|
22
|
+
/** Can mount a persistent cache shared by all runtimes of one pool. */
|
|
23
|
+
persistentCache?: boolean;
|
|
22
24
|
/** Provider hard cap on runtime lifetime, if any. */
|
|
23
25
|
maxTtlSec?: number;
|
|
24
26
|
/** Continuous runtime limit for the configured infrastructure tier. */
|
|
@@ -45,11 +47,23 @@ export interface ProvisionRequest {
|
|
|
45
47
|
/** Worker env: MCP_BASE_URL, AGENT_ID, API key or scoped token, pool env. */
|
|
46
48
|
env: Record<string, string>;
|
|
47
49
|
resources?: { cpu?: number; memoryMb?: number };
|
|
50
|
+
/** Provider-neutral cache identity, allocated by the API rather than the operator. */
|
|
51
|
+
cache?: { key: string; mountPath: string };
|
|
48
52
|
ttlSec?: number;
|
|
49
53
|
/** Ownership labels, used by list() to find runtimes this swarm started. */
|
|
50
54
|
labels: Record<string, string>;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
export interface RuntimeCacheReference {
|
|
58
|
+
key: string;
|
|
59
|
+
poolId: string;
|
|
60
|
+
ownerId: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function poolCacheKey(poolId: string, generation?: string): string {
|
|
64
|
+
return `swarm-cache-${poolId}${generation ? `-${generation}` : ""}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
53
67
|
export interface RuntimeHandle {
|
|
54
68
|
kind: RuntimeProviderKind;
|
|
55
69
|
/** Container id, sandbox id, or pod name. */
|
|
@@ -74,6 +88,8 @@ export interface RuntimeProvider {
|
|
|
74
88
|
status(target: RuntimeTargetConfig, handle: RuntimeHandle): Promise<RuntimeStatus>;
|
|
75
89
|
/** Every runtime this swarm owns on the target, found by labels. Used to reap orphans. */
|
|
76
90
|
list(target: RuntimeTargetConfig, ownerId: string): Promise<RuntimeHandle[]>;
|
|
91
|
+
/** Remove an owned pool cache after its runtimes stop; never force detach. */
|
|
92
|
+
releaseCache?(target: RuntimeTargetConfig, cache: RuntimeCacheReference): Promise<void>;
|
|
77
93
|
renew?(
|
|
78
94
|
target: RuntimeTargetConfig,
|
|
79
95
|
handle: RuntimeHandle,
|
|
@@ -44,6 +44,14 @@ type AgentTaskConfig = {
|
|
|
44
44
|
parentTaskId?: string;
|
|
45
45
|
requestedByUserId?: string;
|
|
46
46
|
outputSchema?: Record<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* Dispatch even when the follow-up re-delegation guard blocks this step.
|
|
49
|
+
* The guard applies only to a run launched from a follow-up task in a Slack
|
|
50
|
+
* thread, and decides once for the whole run at its first new step: it
|
|
51
|
+
* blocks a repeat of work already completed in the thread, or a run past the
|
|
52
|
+
* thread's dispatch cap. A blocked run's later new steps are blocked too.
|
|
53
|
+
*/
|
|
54
|
+
allowRedelegation?: boolean;
|
|
47
55
|
/**
|
|
48
56
|
* Block until the dispatched task reaches a terminal status and journal
|
|
49
57
|
* its real output. Defaults to `true` — this is the natural semantic for
|
|
@@ -63,6 +63,8 @@ declare module "swarm-sdk" {
|
|
|
63
63
|
parentTaskId?: string;
|
|
64
64
|
requestedByUserId?: string;
|
|
65
65
|
outputSchema?: Record<string, unknown>;
|
|
66
|
+
/** Dispatch even when the follow-up re-delegation guard blocks this step (a run launched from a follow-up task in a Slack thread, checked at its first new step). */
|
|
67
|
+
allowRedelegation?: boolean;
|
|
66
68
|
/** Wait for the dispatched task to reach a terminal status before resolving. Default: true. */
|
|
67
69
|
waitForCompletion?: boolean;
|
|
68
70
|
/** Max ms to wait for a terminal status before throwing. Default: 2h. Only used when waitForCompletion is true. */
|
|
@@ -760,6 +762,26 @@ declare module "swarm-sdk" {
|
|
|
760
762
|
label: string,
|
|
761
763
|
config: { prompt: string; model?: string; schema?: Record<string, unknown> },
|
|
762
764
|
): Promise<unknown>;
|
|
765
|
+
/**
|
|
766
|
+
* Dispatch a task and journal its result under `label`.
|
|
767
|
+
*
|
|
768
|
+
* Resolves to the task's OUTPUT on success: the stored output string (JSON
|
|
769
|
+
* text under `outputSchema`, so parse it yourself), or `null` when the task
|
|
770
|
+
* stored none. It is never a task record: on a string, `result.taskId` and
|
|
771
|
+
* `result.status` are undefined; on `null`, reading them throws a TypeError.
|
|
772
|
+
* Other shapes:
|
|
773
|
+
* - `failOnTaskFailure: false` and the task ends failed/cancelled/superseded:
|
|
774
|
+
* `{ taskId, status: "failed", error }`.
|
|
775
|
+
* - `waitForCompletion: false`: the first call still waits up to ~30s while
|
|
776
|
+
* the server polls the task. Done in that window: the output, as on
|
|
777
|
+
* success. Failed or cancelled in that window: throws, or
|
|
778
|
+
* `{ taskId, status: "failed", error }` with `failOnTaskFailure: false`.
|
|
779
|
+
* Still running: `{ taskId, status }`.
|
|
780
|
+
*
|
|
781
|
+
* To get a step's task id, query by its context key:
|
|
782
|
+
* `SELECT id, status, output FROM agent_tasks WHERE contextKey = 'script-run:<runId>:<label>'
|
|
783
|
+
* AND taskType = 'script-run-step' ORDER BY createdAt DESC, rowid DESC LIMIT 1`.
|
|
784
|
+
*/
|
|
763
785
|
agentTask(label: string, config: AgentTaskStepConfig): Promise<unknown>;
|
|
764
786
|
swarmScript(
|
|
765
787
|
label: string,
|
|
@@ -45,6 +45,8 @@ declare module "swarm-sdk" {
|
|
|
45
45
|
parentTaskId?: string;
|
|
46
46
|
requestedByUserId?: string;
|
|
47
47
|
outputSchema?: Record<string, unknown>;
|
|
48
|
+
/** Dispatch even when the follow-up re-delegation guard blocks this step (a run launched from a follow-up task in a Slack thread, checked at its first new step). */
|
|
49
|
+
allowRedelegation?: boolean;
|
|
48
50
|
/** Wait for the dispatched task to reach a terminal status before resolving. Default: true. */
|
|
49
51
|
waitForCompletion?: boolean;
|
|
50
52
|
/** Max ms to wait for a terminal status before throwing. Default: 2h. Only used when waitForCompletion is true. */
|
|
@@ -742,6 +744,26 @@ declare module "swarm-sdk" {
|
|
|
742
744
|
label: string,
|
|
743
745
|
config: { prompt: string; model?: string; schema?: Record<string, unknown> },
|
|
744
746
|
): Promise<unknown>;
|
|
747
|
+
/**
|
|
748
|
+
* Dispatch a task and journal its result under `label`.
|
|
749
|
+
*
|
|
750
|
+
* Resolves to the task's OUTPUT on success: the stored output string (JSON
|
|
751
|
+
* text under `outputSchema`, so parse it yourself), or `null` when the task
|
|
752
|
+
* stored none. It is never a task record: on a string, `result.taskId` and
|
|
753
|
+
* `result.status` are undefined; on `null`, reading them throws a TypeError.
|
|
754
|
+
* Other shapes:
|
|
755
|
+
* - `failOnTaskFailure: false` and the task ends failed/cancelled/superseded:
|
|
756
|
+
* `{ taskId, status: "failed", error }`.
|
|
757
|
+
* - `waitForCompletion: false`: the first call still waits up to ~30s while
|
|
758
|
+
* the server polls the task. Done in that window: the output, as on
|
|
759
|
+
* success. Failed or cancelled in that window: throws, or
|
|
760
|
+
* `{ taskId, status: "failed", error }` with `failOnTaskFailure: false`.
|
|
761
|
+
* Still running: `{ taskId, status }`.
|
|
762
|
+
*
|
|
763
|
+
* To get a step's task id, query by its context key:
|
|
764
|
+
* `SELECT id, status, output FROM agent_tasks WHERE contextKey = 'script-run:<runId>:<label>'
|
|
765
|
+
* AND taskType = 'script-run-step' ORDER BY createdAt DESC, rowid DESC LIMIT 1`.
|
|
766
|
+
*/
|
|
745
767
|
agentTask(label: string, config: AgentTaskStepConfig): Promise<unknown>;
|
|
746
768
|
swarmScript(
|
|
747
769
|
label: string,
|
package/src/server-user.ts
CHANGED
package/src/slack/commands.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { App } from "@slack/bolt";
|
|
|
2
2
|
import { getAllAgents, getAllTasks } from "../be/db";
|
|
3
3
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
4
4
|
import { EXTERNAL_CHANNEL_BLOCKED_REPLY, shouldBlockChannel } from "./external-channel";
|
|
5
|
+
import { isSlackLeaveThreadDisabled } from "./left-threads";
|
|
5
6
|
|
|
6
7
|
export function registerCommandHandler(app: App): void {
|
|
7
8
|
app.command("/agent-swarm-status", async ({ ack, respond, command, client }) => {
|
|
@@ -88,9 +89,12 @@ export function registerCommandHandler(app: App): void {
|
|
|
88
89
|
• Follow-up tasks auto-depend on the active task in the thread for natural sequencing`);
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
const leaveLine = isSlackLeaveThreadDisabled()
|
|
93
|
+
? ""
|
|
94
|
+
: "\n• `!leave` in a thread - Make the swarm ignore that thread until someone @mentions it";
|
|
91
95
|
sections.push(`*Commands:*
|
|
92
96
|
• \`/agent-swarm-status\` - Show all agents and their current status
|
|
93
|
-
• \`/agent-swarm-help\` - Show this help message`);
|
|
97
|
+
• \`/agent-swarm-help\` - Show this help message${leaveLine}`);
|
|
94
98
|
|
|
95
99
|
const blocks = [
|
|
96
100
|
{
|
package/src/slack/handlers.ts
CHANGED
|
@@ -52,7 +52,13 @@ import {
|
|
|
52
52
|
leaveExternalChannel,
|
|
53
53
|
shouldBlockChannel,
|
|
54
54
|
} from "./external-channel";
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
isSlackLeaveThreadCommand,
|
|
57
|
+
isSlackLeaveThreadDisabled,
|
|
58
|
+
leaveSlackThread,
|
|
59
|
+
SLACK_LEFT_THREAD_CONFIRMATION,
|
|
60
|
+
shouldSkipLeftSlackThread,
|
|
61
|
+
} from "./left-threads";
|
|
56
62
|
import {
|
|
57
63
|
collectSlackMessageFiles,
|
|
58
64
|
extractSlackMessageText,
|
|
@@ -491,7 +497,36 @@ export function registerMessageHandler(app: App): void {
|
|
|
491
497
|
// Check if bot was mentioned (in original text only)
|
|
492
498
|
const botMentioned = !!cachedBotUserId && !!msg.text?.includes(`<@${cachedBotUserId}>`);
|
|
493
499
|
|
|
494
|
-
//
|
|
500
|
+
// `!leave` in a thread the swarm is in: a human makes the swarm leave it,
|
|
501
|
+
// in code, with no workflow event, buffering, task, or model call.
|
|
502
|
+
// Any task bound to the thread counts, assigned or not, as does a thread
|
|
503
|
+
// the swarm started. Checked before the left-thread gate so `@bot !leave`
|
|
504
|
+
// leaves rather than rejoining. The confirmation is ephemeral, to the
|
|
505
|
+
// sender only.
|
|
506
|
+
if (
|
|
507
|
+
msg.thread_ts &&
|
|
508
|
+
!isSlackLeaveThreadDisabled() &&
|
|
509
|
+
isSlackLeaveThreadCommand(msg.text, cachedBotUserId) &&
|
|
510
|
+
((await getMostRecentTaskInThread(msg.channel, msg.thread_ts)) !== null ||
|
|
511
|
+
(await wasThreadStartedBySwarm(client, msg.channel, msg.thread_ts)))
|
|
512
|
+
) {
|
|
513
|
+
noteSlackInboundSideEffect("thread_leave");
|
|
514
|
+
await leaveSlackThread(msg.channel, msg.thread_ts, { slackUserId: msg.user });
|
|
515
|
+
console.log(`[Slack] Left thread ${msg.channel}/${msg.thread_ts}: !leave from ${msg.user}`);
|
|
516
|
+
try {
|
|
517
|
+
await client.chat.postEphemeral({
|
|
518
|
+
channel: msg.channel,
|
|
519
|
+
user: msg.user,
|
|
520
|
+
thread_ts: msg.thread_ts,
|
|
521
|
+
text: SLACK_LEFT_THREAD_CONFIRMATION,
|
|
522
|
+
});
|
|
523
|
+
} catch (error) {
|
|
524
|
+
console.error("[Slack] Failed to confirm !leave:", error);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A thread the swarm left (slack-leave-thread or `!leave`) stays silent until someone
|
|
495
530
|
// @mentions the bot. Checked before the workflow event, buffering, routing,
|
|
496
531
|
// or any AI call.
|
|
497
532
|
if (await shouldSkipLeftSlackThread(msg.channel, msg.thread_ts, botMentioned)) {
|
|
@@ -2,7 +2,8 @@ import { deleteKv, getKv, upsertKv } from "../be/db";
|
|
|
2
2
|
import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Slack threads the swarm left via `slack-leave-thread
|
|
5
|
+
* Slack threads the swarm left, via the `slack-leave-thread` tool or a
|
|
6
|
+
* human's `!leave` in the thread.
|
|
6
7
|
*
|
|
7
8
|
* A left thread drops every inbound message that does not @mention the bot,
|
|
8
9
|
* in code, before buffering, routing, or any AI call. A mention clears the
|
|
@@ -11,7 +12,7 @@ import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
|
11
12
|
*/
|
|
12
13
|
export const SLACK_LEFT_THREAD_KV_NAMESPACE = "slack-left-thread";
|
|
13
14
|
|
|
14
|
-
/** Operator flag that turns the
|
|
15
|
+
/** Operator flag that turns leaving threads off: the tool and `!leave`. */
|
|
15
16
|
export const SLACK_LEAVE_THREAD_DISABLE = "SLACK_LEAVE_THREAD_DISABLE";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -22,19 +23,41 @@ export function isSlackLeaveThreadDisabled(): boolean {
|
|
|
22
23
|
return isEnvFlagEnabled(SLACK_LEAVE_THREAD_DISABLE, false);
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Ephemeral reply to the human who sent `!leave`. */
|
|
27
|
+
export const SLACK_LEFT_THREAD_CONFIRMATION =
|
|
28
|
+
"Left this thread. @mention the swarm to bring it back.";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True when a thread message is the `!leave` command: exactly `!leave`,
|
|
32
|
+
* optionally after @mentions of the bot. A message that mentions anyone else
|
|
33
|
+
* is not addressed to the swarm and does not match.
|
|
34
|
+
*/
|
|
35
|
+
export function isSlackLeaveThreadCommand(
|
|
36
|
+
text: string | undefined,
|
|
37
|
+
botUserId: string | null,
|
|
38
|
+
): boolean {
|
|
39
|
+
const withoutBotMentions = botUserId
|
|
40
|
+
? (text ?? "").replaceAll(`<@${botUserId}>`, "")
|
|
41
|
+
: (text ?? "");
|
|
42
|
+
return withoutBotMentions.trim() === "!leave";
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
function threadKey(channelId: string, threadTs: string): string {
|
|
26
46
|
return `${channelId}:${threadTs}`;
|
|
27
47
|
}
|
|
28
48
|
|
|
49
|
+
/** Who left the thread: an agent via the tool, or a Slack user via `!leave`. */
|
|
50
|
+
export type SlackThreadLeftBy = { agentId: string } | { slackUserId: string };
|
|
51
|
+
|
|
29
52
|
export async function leaveSlackThread(
|
|
30
53
|
channelId: string,
|
|
31
54
|
threadTs: string,
|
|
32
|
-
|
|
55
|
+
leftBy: SlackThreadLeftBy,
|
|
33
56
|
): Promise<void> {
|
|
34
57
|
await upsertKv({
|
|
35
58
|
namespace: SLACK_LEFT_THREAD_KV_NAMESPACE,
|
|
36
59
|
key: threadKey(channelId, threadTs),
|
|
37
|
-
value: {
|
|
60
|
+
value: { ...leftBy, leftAt: new Date().toISOString() },
|
|
38
61
|
valueType: "json",
|
|
39
62
|
});
|
|
40
63
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-up re-delegation guard, shared by every agent dispatch path that can
|
|
3
|
+
* run inside a follow-up session: MCP `send-task` and durable-run
|
|
4
|
+
* `ctx.step.agentTask` steps (`src/http/script-runs.ts`).
|
|
5
|
+
*
|
|
6
|
+
* When the dispatching session's source task is a `follow-up` in a Slack
|
|
7
|
+
* thread, two rules stop the cycle
|
|
8
|
+
* worker completes → follow-up → Lead re-delegates → repeat,
|
|
9
|
+
* while a distinct next step (step 2 of a chain, a fix round, a retry) passes:
|
|
10
|
+
*
|
|
11
|
+
* 1. same-work: the new task matches a completed task in the thread under the
|
|
12
|
+
* dedup similarity rules. Bypassed when a task in the thread was cancelled
|
|
13
|
+
* or failed after that completion: the work was interrupted or went wrong,
|
|
14
|
+
* so re-dispatch is the correct response, not a deduped no-op.
|
|
15
|
+
* 2. loop-cap: the caller already made FOLLOW_UP_DISPATCH_CAP dispatches into
|
|
16
|
+
* the thread within FOLLOW_UP_DISPATCH_WINDOW_MINUTES. A durable script run
|
|
17
|
+
* counts once however many steps it dispatched. Independent of similarity,
|
|
18
|
+
* so a loop that rewords each re-delegation still stops.
|
|
19
|
+
*
|
|
20
|
+
* Callers bypass both with `allowRedelegation` and log the override. Each
|
|
21
|
+
* caller evaluates the rules inside the write transaction that inserts the
|
|
22
|
+
* task, so two concurrent dispatches into one thread (send-task or a run's
|
|
23
|
+
* first step, in any mix) cannot both read the count below the cap.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
countAgentDispatchesInThread,
|
|
27
|
+
findCompletedTasksInThread,
|
|
28
|
+
findRecentCancelledTaskInThread,
|
|
29
|
+
findRecentFailedTaskInThread,
|
|
30
|
+
getTaskById,
|
|
31
|
+
} from "../be/db";
|
|
32
|
+
import { type DedupMatch, matchSimilarTask } from "../tools/task-dedup";
|
|
33
|
+
|
|
34
|
+
/** How far back the same-work rule looks for a completed task in the thread. */
|
|
35
|
+
export const FOLLOW_UP_SAME_WORK_WINDOW_MINUTES = 2880; // 48 hours
|
|
36
|
+
/**
|
|
37
|
+
* Loop cap: dispatches one agent may make into one Slack thread per window
|
|
38
|
+
* before a follow-up session needs allowRedelegation. A long legitimate chain
|
|
39
|
+
* (plan, implement, review, fix, re-review, fix, CI fix, retry) is about 8
|
|
40
|
+
* dispatches; a runaway loop makes one per worker run (minutes), so it hits
|
|
41
|
+
* 10 within a few hours and burns at most 10 worker sessions.
|
|
42
|
+
*/
|
|
43
|
+
export const FOLLOW_UP_DISPATCH_CAP = 10;
|
|
44
|
+
export const FOLLOW_UP_DISPATCH_WINDOW_MINUTES = 360; // 6 hours
|
|
45
|
+
|
|
46
|
+
/** Rows per page when the same-work rule scans a thread's completions. */
|
|
47
|
+
export const FOLLOW_UP_SAME_WORK_PAGE_SIZE = 200;
|
|
48
|
+
|
|
49
|
+
export type FollowUpThread = { channelId: string; threadTs: string; userId?: string };
|
|
50
|
+
|
|
51
|
+
export type FollowUpRedelegationBlock = {
|
|
52
|
+
rule: "same-work" | "loop-cap";
|
|
53
|
+
message: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The Slack thread the guard applies to, or null when the source task is not
|
|
58
|
+
* a `follow-up` in a Slack thread. A `resume` source is intentionally exempt:
|
|
59
|
+
* a resume IS the legitimate re-dispatch.
|
|
60
|
+
*/
|
|
61
|
+
export async function followUpThreadForSourceTask(
|
|
62
|
+
sourceTaskId: string | null | undefined,
|
|
63
|
+
): Promise<FollowUpThread | null> {
|
|
64
|
+
if (!sourceTaskId) return null;
|
|
65
|
+
const sourceTask = await getTaskById(sourceTaskId);
|
|
66
|
+
if (
|
|
67
|
+
sourceTask?.taskType !== "follow-up" ||
|
|
68
|
+
!sourceTask.slackChannelId ||
|
|
69
|
+
!sourceTask.slackThreadTs
|
|
70
|
+
) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
channelId: sourceTask.slackChannelId,
|
|
75
|
+
threadTs: sourceTask.slackThreadTs,
|
|
76
|
+
userId: sourceTask.slackUserId,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The most recent completed task in the thread's same-work window whose
|
|
82
|
+
* description matches this dispatch. Pages through every completion in the
|
|
83
|
+
* window (an older match can sit behind any number of newer unrelated ones),
|
|
84
|
+
* one bounded query per page, and stops at the first match.
|
|
85
|
+
*/
|
|
86
|
+
async function findMatchingCompletedWork(args: {
|
|
87
|
+
channelId: string;
|
|
88
|
+
threadTs: string;
|
|
89
|
+
taskDescription: string;
|
|
90
|
+
targetAgentId?: string;
|
|
91
|
+
}): Promise<DedupMatch | null> {
|
|
92
|
+
let before: { lastUpdatedAt: string; id: string } | undefined;
|
|
93
|
+
for (;;) {
|
|
94
|
+
const page = await findCompletedTasksInThread(
|
|
95
|
+
args.channelId,
|
|
96
|
+
args.threadTs,
|
|
97
|
+
FOLLOW_UP_SAME_WORK_WINDOW_MINUTES,
|
|
98
|
+
FOLLOW_UP_SAME_WORK_PAGE_SIZE,
|
|
99
|
+
before,
|
|
100
|
+
);
|
|
101
|
+
for (const existing of page) {
|
|
102
|
+
const match = matchSimilarTask(existing, args);
|
|
103
|
+
if (match) return match;
|
|
104
|
+
}
|
|
105
|
+
const last = page.at(-1);
|
|
106
|
+
if (!last || page.length < FOLLOW_UP_SAME_WORK_PAGE_SIZE) return null;
|
|
107
|
+
before = { lastUpdatedAt: last.lastUpdatedAt, id: last.id };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The follow-up re-delegation rules. Returns the rule that blocks this
|
|
113
|
+
* dispatch, or null when it may proceed.
|
|
114
|
+
*/
|
|
115
|
+
export async function evaluateFollowUpRedelegation(args: {
|
|
116
|
+
channelId: string;
|
|
117
|
+
threadTs: string;
|
|
118
|
+
creatorAgentId: string;
|
|
119
|
+
taskDescription: string;
|
|
120
|
+
targetAgentId?: string;
|
|
121
|
+
}): Promise<FollowUpRedelegationBlock | null> {
|
|
122
|
+
const { channelId, threadTs } = args;
|
|
123
|
+
const match = await findMatchingCompletedWork(args);
|
|
124
|
+
if (match) {
|
|
125
|
+
const existing = match.task;
|
|
126
|
+
const [cancelled, failed] = await Promise.all([
|
|
127
|
+
findRecentCancelledTaskInThread(channelId, threadTs, FOLLOW_UP_SAME_WORK_WINDOW_MINUTES),
|
|
128
|
+
findRecentFailedTaskInThread(channelId, threadTs, FOLLOW_UP_SAME_WORK_WINDOW_MINUTES),
|
|
129
|
+
]);
|
|
130
|
+
const completedAt = new Date(existing.lastUpdatedAt).getTime();
|
|
131
|
+
const interruptedLater = [cancelled, failed].some(
|
|
132
|
+
(t) => t && new Date(t.lastUpdatedAt).getTime() > completedAt,
|
|
133
|
+
);
|
|
134
|
+
if (!interruptedLater) {
|
|
135
|
+
return {
|
|
136
|
+
rule: "same-work",
|
|
137
|
+
message: `Blocked by the follow-up re-delegation guard (same-work rule): this task matches completed task ${existing.id.slice(0, 8)} in this Slack thread (${match.reason}), so the work was already done. If it must run again, pass allowRedelegation: true.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const dispatched = await countAgentDispatchesInThread(
|
|
143
|
+
channelId,
|
|
144
|
+
threadTs,
|
|
145
|
+
args.creatorAgentId,
|
|
146
|
+
FOLLOW_UP_DISPATCH_WINDOW_MINUTES,
|
|
147
|
+
);
|
|
148
|
+
if (dispatched >= FOLLOW_UP_DISPATCH_CAP) {
|
|
149
|
+
return {
|
|
150
|
+
rule: "loop-cap",
|
|
151
|
+
message: `Blocked by the follow-up re-delegation guard (loop-cap rule): you already dispatched ${dispatched} times into this Slack thread in the last ${FOLLOW_UP_DISPATCH_WINDOW_MINUTES / 60} hours (cap ${FOLLOW_UP_DISPATCH_CAP}; a durable script run counts once). If this is a deliberate next step and not a re-delegation loop, pass allowRedelegation: true.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|