@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
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
|
+
import * as db from "../be/db";
|
|
5
6
|
import {
|
|
6
7
|
closeDb,
|
|
7
8
|
completeTask,
|
|
9
|
+
countAgentDispatchesInThread,
|
|
8
10
|
createAgent,
|
|
9
11
|
createTaskExtended,
|
|
10
12
|
failTask,
|
|
@@ -15,13 +17,20 @@ import {
|
|
|
15
17
|
initDb,
|
|
16
18
|
updateScriptRun,
|
|
17
19
|
} from "../be/db";
|
|
20
|
+
import * as modelValidation from "../be/model-validation";
|
|
18
21
|
import { configSecretName, registerStoredSecret } from "../be/secret-registry";
|
|
19
22
|
import { handleCore } from "../http/core";
|
|
20
23
|
import { handleScriptRuns } from "../http/script-runs";
|
|
21
24
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
22
25
|
import { localProcessScriptExecutor } from "../script-workflows/executor";
|
|
23
26
|
import { setScriptRunExecutor } from "../script-workflows/supervisor";
|
|
24
|
-
import
|
|
27
|
+
import {
|
|
28
|
+
FOLLOW_UP_DISPATCH_CAP,
|
|
29
|
+
FOLLOW_UP_DISPATCH_WINDOW_MINUTES,
|
|
30
|
+
} from "../tasks/follow-up-redelegation-guard";
|
|
31
|
+
import { sendTaskHandler, sendTaskInputSchema } from "../tools/send-task";
|
|
32
|
+
import { ownerCtx } from "../tools/task-tool-ctx";
|
|
33
|
+
import type { AgentTask, CreateTaskOptions } from "../types";
|
|
25
34
|
import {
|
|
26
35
|
clearVolatileSecretsForTesting,
|
|
27
36
|
refreshSecretScrubberCache,
|
|
@@ -691,6 +700,216 @@ describe("/api/script-runs HTTP", () => {
|
|
|
691
700
|
expect(await response.json()).toEqual({ taskId: step?.id, taskOutput: stepOutput });
|
|
692
701
|
});
|
|
693
702
|
|
|
703
|
+
describe("agent-task parentTaskId attach gate", () => {
|
|
704
|
+
let foreignAgentId: string;
|
|
705
|
+
|
|
706
|
+
beforeAll(async () => {
|
|
707
|
+
const foreign = await createAgent({
|
|
708
|
+
name: "parent-attach-foreign",
|
|
709
|
+
isLead: false,
|
|
710
|
+
status: "idle",
|
|
711
|
+
});
|
|
712
|
+
foreignAgentId = foreign.id;
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
async function createRun(runAgentId: string): Promise<string> {
|
|
716
|
+
const created = await dispatch("/api/script-runs", {
|
|
717
|
+
method: "POST",
|
|
718
|
+
agentId: runAgentId,
|
|
719
|
+
body: createBody(),
|
|
720
|
+
});
|
|
721
|
+
expect(created.status).toBe(201);
|
|
722
|
+
return ((await created.json()) as { id: string }).id;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function stepCall(runId: string, runAgentId: string, stepKey: string, parentTaskId?: string) {
|
|
726
|
+
return dispatch(`/api/internal/script-runs/${runId}/agent-task`, {
|
|
727
|
+
method: "POST",
|
|
728
|
+
agentId: runAgentId,
|
|
729
|
+
body: JSON.stringify({ stepKey, task: `do ${stepKey}`, parentTaskId }),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function expectDispatched(runAgentId: string, parentTaskId: string): Promise<void> {
|
|
734
|
+
const runId = await createRun(runAgentId);
|
|
735
|
+
const response = stepCall(runId, runAgentId, "child", parentTaskId);
|
|
736
|
+
const contextKey = `script-run:${runId}:child`;
|
|
737
|
+
let task = await getLatestScriptRunStepTaskByContextKey(contextKey);
|
|
738
|
+
for (let i = 0; i < 50 && !task; i++) {
|
|
739
|
+
await Bun.sleep(20);
|
|
740
|
+
task = await getLatestScriptRunStepTaskByContextKey(contextKey);
|
|
741
|
+
}
|
|
742
|
+
expect(task?.parentTaskId).toBe(parentTaskId);
|
|
743
|
+
await completeTask(task!.id, "done");
|
|
744
|
+
expect((await response).status).toBe(200);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
test("a non-lead run agent cannot parent a step on a foreign task", async () => {
|
|
748
|
+
const foreignParent = await createTaskExtended("foreign thread work", {
|
|
749
|
+
agentId: foreignAgentId,
|
|
750
|
+
creatorAgentId: foreignAgentId,
|
|
751
|
+
source: "mcp",
|
|
752
|
+
});
|
|
753
|
+
const runId = await createRun(agentId);
|
|
754
|
+
|
|
755
|
+
const response = await stepCall(runId, agentId, "steal", foreignParent.id);
|
|
756
|
+
|
|
757
|
+
expect(response.status).toBe(403);
|
|
758
|
+
expect(((await response.json()) as { error: string }).error).toBe(
|
|
759
|
+
`You don't have context for parent task ${foreignParent.id}. Only its assignee, its creator, or a lead can create a child task under it.`,
|
|
760
|
+
);
|
|
761
|
+
const rows = await getDbClient().get<{ c: number }>(
|
|
762
|
+
"SELECT COUNT(*) as c FROM agent_tasks WHERE contextKey = ? OR parentTaskId = ?",
|
|
763
|
+
[`script-run:${runId}:steal`, foreignParent.id],
|
|
764
|
+
);
|
|
765
|
+
expect(rows?.c).toBe(0);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
function foreignTask(): Promise<AgentTask> {
|
|
769
|
+
return createTaskExtended("foreign thread work", {
|
|
770
|
+
agentId: foreignAgentId,
|
|
771
|
+
creatorAgentId: foreignAgentId,
|
|
772
|
+
source: "mcp",
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** A completed step row under `parentTaskId`, left by an unguarded dispatch. */
|
|
777
|
+
async function seedCompletedStep(
|
|
778
|
+
runId: string,
|
|
779
|
+
stepKey: string,
|
|
780
|
+
parentTaskId: string,
|
|
781
|
+
output = "foreign thread output",
|
|
782
|
+
) {
|
|
783
|
+
const step = await createTaskExtended(`do ${stepKey}`, {
|
|
784
|
+
taskType: "script-run-step",
|
|
785
|
+
source: "mcp",
|
|
786
|
+
parentTaskId,
|
|
787
|
+
contextKey: `script-run:${runId}:${stepKey}`,
|
|
788
|
+
});
|
|
789
|
+
await completeTask(step.id, output);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function stepRowCount(runId: string, stepKey: string): Promise<number | undefined> {
|
|
793
|
+
const rows = await getDbClient().get<{ c: number }>(
|
|
794
|
+
"SELECT COUNT(*) as c FROM agent_tasks WHERE contextKey = ?",
|
|
795
|
+
[`script-run:${runId}:${stepKey}`],
|
|
796
|
+
);
|
|
797
|
+
return rows?.c;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** A refused replay returns no output and dispatches nothing beyond the seeded step. */
|
|
801
|
+
async function expectRefusedReplay(
|
|
802
|
+
response: TestResponse,
|
|
803
|
+
storedParentId: string,
|
|
804
|
+
runId: string,
|
|
805
|
+
stepKey: string,
|
|
806
|
+
): Promise<void> {
|
|
807
|
+
expect(response.status).toBe(403);
|
|
808
|
+
expect(response.text).not.toContain("foreign thread output");
|
|
809
|
+
expect(((await response.json()) as { error: string }).error).toContain(storedParentId);
|
|
810
|
+
expect(await stepRowCount(runId, stepKey)).toBe(1);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
test("a replay with a foreign parentTaskId is refused, not answered from the existing step", async () => {
|
|
814
|
+
const foreignParent = await foreignTask();
|
|
815
|
+
const runId = await createRun(agentId);
|
|
816
|
+
await seedCompletedStep(runId, "stale", foreignParent.id);
|
|
817
|
+
|
|
818
|
+
const response = await stepCall(runId, agentId, "stale", foreignParent.id);
|
|
819
|
+
|
|
820
|
+
await expectRefusedReplay(response, foreignParent.id, runId, "stale");
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("a replay that omits parentTaskId is checked against the step's stored parent", async () => {
|
|
824
|
+
const foreignParent = await foreignTask();
|
|
825
|
+
const runId = await createRun(agentId);
|
|
826
|
+
await seedCompletedStep(runId, "omitted", foreignParent.id);
|
|
827
|
+
|
|
828
|
+
const response = await stepCall(runId, agentId, "omitted");
|
|
829
|
+
|
|
830
|
+
await expectRefusedReplay(response, foreignParent.id, runId, "omitted");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("a replay with an authorized but different parentTaskId is checked against the stored parent", async () => {
|
|
834
|
+
const foreignParent = await foreignTask();
|
|
835
|
+
const ownParent = await createTaskExtended("own work", { agentId, source: "mcp" });
|
|
836
|
+
const runId = await createRun(agentId);
|
|
837
|
+
await seedCompletedStep(runId, "swapped", foreignParent.id);
|
|
838
|
+
|
|
839
|
+
const response = await stepCall(runId, agentId, "swapped", ownParent.id);
|
|
840
|
+
|
|
841
|
+
await expectRefusedReplay(response, foreignParent.id, runId, "swapped");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("a replay with an unknown parentTaskId is checked against the stored parent", async () => {
|
|
845
|
+
const foreignParent = await foreignTask();
|
|
846
|
+
const runId = await createRun(agentId);
|
|
847
|
+
await seedCompletedStep(runId, "unknown", foreignParent.id);
|
|
848
|
+
|
|
849
|
+
const response = await stepCall(runId, agentId, "unknown", crypto.randomUUID());
|
|
850
|
+
|
|
851
|
+
await expectRefusedReplay(response, foreignParent.id, runId, "unknown");
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("a replay that omits parentTaskId still answers from a step under an attachable parent", async () => {
|
|
855
|
+
const ownParent = await createTaskExtended("own work", { agentId, source: "mcp" });
|
|
856
|
+
const runId = await createRun(agentId);
|
|
857
|
+
await seedCompletedStep(runId, "own", ownParent.id, "own thread output");
|
|
858
|
+
|
|
859
|
+
const response = await stepCall(runId, agentId, "own");
|
|
860
|
+
|
|
861
|
+
expect(response.status).toBe(200);
|
|
862
|
+
expect(response.text).toContain("own thread output");
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("a new step under an unknown parentTaskId is dispatched and answered", async () => {
|
|
866
|
+
await expectDispatched(agentId, crypto.randomUUID());
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("a replay of a step stored under an unknown parent still answers", async () => {
|
|
870
|
+
const unknownParentId = crypto.randomUUID();
|
|
871
|
+
const runId = await createRun(agentId);
|
|
872
|
+
await seedCompletedStep(runId, "ghost", unknownParentId, "ghost output");
|
|
873
|
+
|
|
874
|
+
const sameParent = await stepCall(runId, agentId, "ghost", unknownParentId);
|
|
875
|
+
expect(sameParent.status).toBe(200);
|
|
876
|
+
expect(sameParent.text).toContain("ghost output");
|
|
877
|
+
|
|
878
|
+
const omittedParent = await stepCall(runId, agentId, "ghost");
|
|
879
|
+
expect(omittedParent.status).toBe(200);
|
|
880
|
+
expect(omittedParent.text).toContain("ghost output");
|
|
881
|
+
expect(await stepRowCount(runId, "ghost")).toBe(1);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("a run agent can parent a step on a task assigned to it", async () => {
|
|
885
|
+
const ownParent = await createTaskExtended("own work", {
|
|
886
|
+
agentId,
|
|
887
|
+
creatorAgentId: foreignAgentId,
|
|
888
|
+
source: "mcp",
|
|
889
|
+
});
|
|
890
|
+
await expectDispatched(agentId, ownParent.id);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
test("a run agent can parent a step on a task it created", async () => {
|
|
894
|
+
const createdParent = await createTaskExtended("delegated work", {
|
|
895
|
+
agentId: foreignAgentId,
|
|
896
|
+
creatorAgentId: agentId,
|
|
897
|
+
source: "mcp",
|
|
898
|
+
});
|
|
899
|
+
await expectDispatched(agentId, createdParent.id);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("a lead run agent can parent a step on any task", async () => {
|
|
903
|
+
const lead = await createAgent({ name: "parent-attach-lead", isLead: true, status: "idle" });
|
|
904
|
+
const foreignParent = await createTaskExtended("foreign thread work", {
|
|
905
|
+
agentId: foreignAgentId,
|
|
906
|
+
creatorAgentId: foreignAgentId,
|
|
907
|
+
source: "mcp",
|
|
908
|
+
});
|
|
909
|
+
await expectDispatched(lead.id, foreignParent.id);
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
694
913
|
test("cancel leaves terminal script runs unchanged", async () => {
|
|
695
914
|
const created = await dispatch("/api/script-runs", {
|
|
696
915
|
method: "POST",
|
|
@@ -718,6 +937,469 @@ describe("/api/script-runs HTTP", () => {
|
|
|
718
937
|
});
|
|
719
938
|
});
|
|
720
939
|
|
|
940
|
+
describe("ctx.step.agentTask follow-up re-delegation guard", () => {
|
|
941
|
+
/** A session task of the launching agent in a Slack thread (a follow-up by default). */
|
|
942
|
+
async function session(channel: string, thread: string, taskType = "follow-up") {
|
|
943
|
+
return createTaskExtended("Worker task completed — review needed.", {
|
|
944
|
+
agentId,
|
|
945
|
+
source: "system",
|
|
946
|
+
taskType,
|
|
947
|
+
slackChannelId: channel,
|
|
948
|
+
slackThreadTs: thread,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function completedWork(channel: string, thread: string, text: string) {
|
|
953
|
+
const task = await createTaskExtended(text, {
|
|
954
|
+
slackChannelId: channel,
|
|
955
|
+
slackThreadTs: thread,
|
|
956
|
+
});
|
|
957
|
+
await completeTask(task.id, "done");
|
|
958
|
+
return task;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function launch(sourceTaskId?: string): Promise<string> {
|
|
962
|
+
const created = await dispatch("/api/script-runs", {
|
|
963
|
+
method: "POST",
|
|
964
|
+
agentId,
|
|
965
|
+
body: createBody(),
|
|
966
|
+
headers: sourceTaskId ? { "X-Source-Task-Id": sourceTaskId } : {},
|
|
967
|
+
});
|
|
968
|
+
expect(created.status).toBe(201);
|
|
969
|
+
return ((await created.json()) as { id: string }).id;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Call the step route and complete the step task it creates (as a worker
|
|
974
|
+
* would), so the route's long-poll returns 200. A blocked step returns at once.
|
|
975
|
+
*/
|
|
976
|
+
async function step(
|
|
977
|
+
runId: string,
|
|
978
|
+
stepKey: string,
|
|
979
|
+
task: string,
|
|
980
|
+
extra: Record<string, unknown> = {},
|
|
981
|
+
): Promise<TestResponse> {
|
|
982
|
+
let settled = false;
|
|
983
|
+
const response = dispatch(`/api/internal/script-runs/${runId}/agent-task`, {
|
|
984
|
+
method: "POST",
|
|
985
|
+
agentId,
|
|
986
|
+
body: JSON.stringify({ stepKey, task, ...extra }),
|
|
987
|
+
}).finally(() => {
|
|
988
|
+
settled = true;
|
|
989
|
+
});
|
|
990
|
+
const contextKey = `script-run:${runId}:${stepKey}`;
|
|
991
|
+
for (let i = 0; i < 100 && !settled; i++) {
|
|
992
|
+
const created = await getLatestScriptRunStepTaskByContextKey(contextKey);
|
|
993
|
+
if (created) {
|
|
994
|
+
if (created.status !== "completed") await completeTask(created.id, "done");
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
await Bun.sleep(10);
|
|
998
|
+
}
|
|
999
|
+
return response;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Dispatches by the launching agent already in the thread, as send-task makes them. */
|
|
1003
|
+
async function sendTaskDispatches(channel: string, thread: string, count: number) {
|
|
1004
|
+
for (let i = 0; i < count; i++) {
|
|
1005
|
+
await createTaskExtended(`earlier send-task dispatch ${i} unique words ${i * 19}`, {
|
|
1006
|
+
creatorAgentId: agentId,
|
|
1007
|
+
source: "mcp",
|
|
1008
|
+
slackChannelId: channel,
|
|
1009
|
+
slackThreadTs: thread,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
test("records the launching session's task, but not another agent's", async () => {
|
|
1015
|
+
const own = await session("C_RUN_SRC", "1.1");
|
|
1016
|
+
const runId = await launch(own.id);
|
|
1017
|
+
expect(
|
|
1018
|
+
await getDbClient().get("SELECT sourceTaskId FROM script_runs WHERE id = ?", [runId]),
|
|
1019
|
+
).toEqual({ sourceTaskId: own.id });
|
|
1020
|
+
|
|
1021
|
+
const other = await createAgent({ name: "guard-other-agent", isLead: false, status: "idle" });
|
|
1022
|
+
const foreign = await createTaskExtended("someone else's session", { agentId: other.id });
|
|
1023
|
+
const foreignRun = await launch(foreign.id);
|
|
1024
|
+
expect(
|
|
1025
|
+
await getDbClient().get("SELECT sourceTaskId FROM script_runs WHERE id = ?", [foreignRun]),
|
|
1026
|
+
).toEqual({ sourceTaskId: null });
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("a run that repeats completed thread work is blocked unless allowRedelegation", async () => {
|
|
1030
|
+
const done = await completedWork(
|
|
1031
|
+
"C_RUN_SAME",
|
|
1032
|
+
"2.1",
|
|
1033
|
+
"rebuild the pricing deck from the new numbers",
|
|
1034
|
+
);
|
|
1035
|
+
const followUp = await session("C_RUN_SAME", "2.1");
|
|
1036
|
+
const runId = await launch(followUp.id);
|
|
1037
|
+
|
|
1038
|
+
const blocked = await step(runId, "rebuild", "rebuild the pricing deck from the new numbers");
|
|
1039
|
+
expect(blocked.status).toBe(422);
|
|
1040
|
+
const body = (await blocked.json()) as { error: string; rule: string };
|
|
1041
|
+
expect(body.rule).toBe("same-work");
|
|
1042
|
+
expect(body.error).toContain(done.id.slice(0, 8));
|
|
1043
|
+
expect(body.error).toContain("allowRedelegation: true");
|
|
1044
|
+
expect(await getLatestScriptRunStepTaskByContextKey(`script-run:${runId}:rebuild`)).toBeNull();
|
|
1045
|
+
|
|
1046
|
+
const allowed = await step(runId, "rebuild", "rebuild the pricing deck from the new numbers", {
|
|
1047
|
+
allowRedelegation: true,
|
|
1048
|
+
});
|
|
1049
|
+
expect(allowed.status).toBe(200);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test("later steps are never compared against the run's own completed steps", async () => {
|
|
1053
|
+
const followUp = await session("C_RUN_STEPS", "3.1");
|
|
1054
|
+
const runId = await launch(followUp.id);
|
|
1055
|
+
const text = "render slide variations for the seed deck review";
|
|
1056
|
+
|
|
1057
|
+
const first = await step(runId, "slide-1", text, { parentTaskId: followUp.id });
|
|
1058
|
+
expect(first.status).toBe(200);
|
|
1059
|
+
const firstTask = await getLatestScriptRunStepTaskByContextKey(`script-run:${runId}:slide-1`);
|
|
1060
|
+
expect(firstTask?.slackThreadTs).toBe("3.1");
|
|
1061
|
+
expect(firstTask?.status).toBe("completed");
|
|
1062
|
+
expect(firstTask?.creatorAgentId).toBe(agentId);
|
|
1063
|
+
|
|
1064
|
+
const second = await step(runId, "slide-2", text, { parentTaskId: followUp.id });
|
|
1065
|
+
expect(second.status).toBe(200);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("a 29-step run counts once, and its later steps never hit the loop cap", async () => {
|
|
1069
|
+
await sendTaskDispatches("C_RUN_LONG", "4.1", FOLLOW_UP_DISPATCH_CAP - 1);
|
|
1070
|
+
const followUp = await session("C_RUN_LONG", "4.1");
|
|
1071
|
+
const runId = await launch(followUp.id);
|
|
1072
|
+
|
|
1073
|
+
const first = await step(runId, "slide-1", "deck slide 1 opening words", {
|
|
1074
|
+
parentTaskId: followUp.id,
|
|
1075
|
+
});
|
|
1076
|
+
expect(first.status).toBe(200);
|
|
1077
|
+
// The thread now holds 10 dispatches; a per-step cap check would block these.
|
|
1078
|
+
const rest = await Promise.all(
|
|
1079
|
+
Array.from({ length: 28 }, (_, i) =>
|
|
1080
|
+
step(runId, `slide-${i + 2}`, `deck slide ${i + 2} words ${(i + 2) * 23}`, {
|
|
1081
|
+
parentTaskId: followUp.id,
|
|
1082
|
+
}),
|
|
1083
|
+
),
|
|
1084
|
+
);
|
|
1085
|
+
expect(rest.map((r) => r.status)).toEqual(Array(28).fill(200));
|
|
1086
|
+
expect(
|
|
1087
|
+
await countAgentDispatchesInThread(
|
|
1088
|
+
"C_RUN_LONG",
|
|
1089
|
+
"4.1",
|
|
1090
|
+
agentId,
|
|
1091
|
+
FOLLOW_UP_DISPATCH_WINDOW_MINUTES,
|
|
1092
|
+
),
|
|
1093
|
+
).toBe(FOLLOW_UP_DISPATCH_CAP);
|
|
1094
|
+
}, 30_000);
|
|
1095
|
+
|
|
1096
|
+
test("after 10 dispatches in the thread, the next run is blocked by the loop cap", async () => {
|
|
1097
|
+
const followUp = await session("C_RUN_CAP", "5.1");
|
|
1098
|
+
await sendTaskDispatches("C_RUN_CAP", "5.1", FOLLOW_UP_DISPATCH_CAP / 2);
|
|
1099
|
+
for (let r = 0; r < FOLLOW_UP_DISPATCH_CAP / 2; r++) {
|
|
1100
|
+
const earlier = await launch(followUp.id);
|
|
1101
|
+
// Random words, so no earlier step matches a later run's first step.
|
|
1102
|
+
const responses = await Promise.all(
|
|
1103
|
+
[0, 1].map((i) =>
|
|
1104
|
+
step(earlier, `s-${i}`, Array.from({ length: 4 }, () => crypto.randomUUID()).join(" "), {
|
|
1105
|
+
parentTaskId: followUp.id,
|
|
1106
|
+
}),
|
|
1107
|
+
),
|
|
1108
|
+
);
|
|
1109
|
+
expect(responses.map((r) => r.status)).toEqual([200, 200]);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const runId = await launch(followUp.id);
|
|
1113
|
+
const blocked = await step(runId, "next", "publish the investor update email draft");
|
|
1114
|
+
expect(blocked.status).toBe(422);
|
|
1115
|
+
const body = (await blocked.json()) as { error: string; rule: string };
|
|
1116
|
+
expect(body.rule).toBe("loop-cap");
|
|
1117
|
+
expect(body.error).toContain(`dispatched ${FOLLOW_UP_DISPATCH_CAP} times`);
|
|
1118
|
+
|
|
1119
|
+
const allowed = await step(runId, "next", "publish the investor update email draft", {
|
|
1120
|
+
allowRedelegation: true,
|
|
1121
|
+
});
|
|
1122
|
+
expect(allowed.status).toBe(200);
|
|
1123
|
+
}, 30_000);
|
|
1124
|
+
|
|
1125
|
+
test("a replayed stepKey is never blocked, even after the thread hit the cap", async () => {
|
|
1126
|
+
const followUp = await session("C_RUN_REPLAY", "6.1");
|
|
1127
|
+
const runId = await launch(followUp.id);
|
|
1128
|
+
const first = await step(runId, "only", "compile the weekly metrics digest");
|
|
1129
|
+
expect(first.status).toBe(200);
|
|
1130
|
+
|
|
1131
|
+
await sendTaskDispatches("C_RUN_REPLAY", "6.1", FOLLOW_UP_DISPATCH_CAP);
|
|
1132
|
+
await completedWork("C_RUN_REPLAY", "6.1", "compile the weekly metrics digest");
|
|
1133
|
+
|
|
1134
|
+
const replay = await step(runId, "only", "compile the weekly metrics digest");
|
|
1135
|
+
expect(replay.status).toBe(200);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
test("another agent cannot dispatch steps on a run it does not own", async () => {
|
|
1139
|
+
const runId = await launch();
|
|
1140
|
+
const intruder = await createAgent({ name: "guard-intruder", isLead: false, status: "idle" });
|
|
1141
|
+
const response = await dispatch(`/api/internal/script-runs/${runId}/agent-task`, {
|
|
1142
|
+
method: "POST",
|
|
1143
|
+
agentId: intruder.id,
|
|
1144
|
+
body: JSON.stringify({ stepKey: "forged", task: "do work attributed to someone else" }),
|
|
1145
|
+
});
|
|
1146
|
+
expect(response.status).toBe(403);
|
|
1147
|
+
expect(await getLatestScriptRunStepTaskByContextKey(`script-run:${runId}:forged`)).toBeNull();
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
test("a run not launched from a follow-up is unaffected", async () => {
|
|
1151
|
+
await completedWork("C_RUN_PLAIN", "7.1", "refresh the onboarding checklist copy");
|
|
1152
|
+
await sendTaskDispatches("C_RUN_PLAIN", "7.1", FOLLOW_UP_DISPATCH_CAP);
|
|
1153
|
+
const feature = await session("C_RUN_PLAIN", "7.1", "feature");
|
|
1154
|
+
|
|
1155
|
+
for (const sourceTaskId of [feature.id, undefined]) {
|
|
1156
|
+
const runId = await launch(sourceTaskId);
|
|
1157
|
+
const response = await step(runId, "refresh", "refresh the onboarding checklist copy");
|
|
1158
|
+
expect(response.status).toBe(200);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
async function backdate(taskId: string, minutesAgo: number): Promise<void> {
|
|
1163
|
+
const at = new Date(Date.now() - minutesAgo * 60 * 1000).toISOString();
|
|
1164
|
+
await getDbClient().run(
|
|
1165
|
+
"UPDATE agent_tasks SET createdAt = ?, lastUpdatedAt = ? WHERE id = ?",
|
|
1166
|
+
[at, at, taskId],
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function dispatchCount(channel: string, thread: string): Promise<number> {
|
|
1171
|
+
return countAgentDispatchesInThread(
|
|
1172
|
+
channel,
|
|
1173
|
+
thread,
|
|
1174
|
+
agentId,
|
|
1175
|
+
FOLLOW_UP_DISPATCH_WINDOW_MINUTES,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** A send-task from the follow-up session to another worker. */
|
|
1180
|
+
async function sendTask(sourceTaskId: string, task: string) {
|
|
1181
|
+
const target = await createAgent({
|
|
1182
|
+
name: `guard-target-${crypto.randomUUID()}`,
|
|
1183
|
+
isLead: false,
|
|
1184
|
+
status: "idle",
|
|
1185
|
+
maxTasks: 100,
|
|
1186
|
+
});
|
|
1187
|
+
return sendTaskHandler(
|
|
1188
|
+
ownerCtx({ agentId, sourceTaskId }),
|
|
1189
|
+
sendTaskInputSchema.parse({
|
|
1190
|
+
task,
|
|
1191
|
+
agentId: target.id,
|
|
1192
|
+
routingReason: "skill",
|
|
1193
|
+
routingNote: "target owns this work",
|
|
1194
|
+
}),
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const randomText = () => Array.from({ length: 4 }, () => crypto.randomUUID()).join(" ");
|
|
1199
|
+
|
|
1200
|
+
test("a step with no parent is attributed to the follow-up's thread", async () => {
|
|
1201
|
+
const followUp = await session("C_RUN_NOPARENT", "8.1");
|
|
1202
|
+
const first = await launch(followUp.id);
|
|
1203
|
+
const text = "draft the quarterly churn analysis for finance";
|
|
1204
|
+
expect((await step(first, "churn", text)).status).toBe(200);
|
|
1205
|
+
const stepTask = await getLatestScriptRunStepTaskByContextKey(`script-run:${first}:churn`);
|
|
1206
|
+
expect(stepTask?.slackChannelId).toBe("C_RUN_NOPARENT");
|
|
1207
|
+
expect(stepTask?.slackThreadTs).toBe("8.1");
|
|
1208
|
+
expect(await dispatchCount("C_RUN_NOPARENT", "8.1")).toBe(1);
|
|
1209
|
+
|
|
1210
|
+
// Another run repeating the completed step is the same work.
|
|
1211
|
+
const second = await launch(followUp.id);
|
|
1212
|
+
const repeat = await step(second, "churn-again", text);
|
|
1213
|
+
expect(repeat.status).toBe(422);
|
|
1214
|
+
expect(((await repeat.json()) as { rule: string }).rule).toBe("same-work");
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("a step whose parent sits in another thread is attributed to the follow-up's thread", async () => {
|
|
1218
|
+
const followUp = await session("C_RUN_XPARENT", "9.1");
|
|
1219
|
+
const foreignParent = await createTaskExtended("earlier work in another thread", {
|
|
1220
|
+
agentId,
|
|
1221
|
+
slackChannelId: "C_RUN_XPARENT_OTHER",
|
|
1222
|
+
slackThreadTs: "9.9",
|
|
1223
|
+
});
|
|
1224
|
+
const first = await launch(followUp.id);
|
|
1225
|
+
const text = "audit the vendor contracts for renewal dates";
|
|
1226
|
+
expect((await step(first, "audit", text, { parentTaskId: foreignParent.id })).status).toBe(200);
|
|
1227
|
+
const stepTask = await getLatestScriptRunStepTaskByContextKey(`script-run:${first}:audit`);
|
|
1228
|
+
expect(stepTask?.parentTaskId).toBe(foreignParent.id);
|
|
1229
|
+
expect(stepTask?.slackChannelId).toBe("C_RUN_XPARENT");
|
|
1230
|
+
expect(stepTask?.slackThreadTs).toBe("9.1");
|
|
1231
|
+
expect(await dispatchCount("C_RUN_XPARENT", "9.1")).toBe(1);
|
|
1232
|
+
expect(await dispatchCount("C_RUN_XPARENT_OTHER", "9.9")).toBe(0);
|
|
1233
|
+
|
|
1234
|
+
const second = await launch(followUp.id);
|
|
1235
|
+
const repeat = await step(second, "audit-again", text, { parentTaskId: foreignParent.id });
|
|
1236
|
+
expect(repeat.status).toBe(422);
|
|
1237
|
+
expect(((await repeat.json()) as { rule: string }).rule).toBe("same-work");
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Hold the next matching task insert for 300 ms. `started` resolves when it
|
|
1242
|
+
* begins, so a test can start a competing dispatch while the first one sits
|
|
1243
|
+
* between its guard decision and its insert.
|
|
1244
|
+
*/
|
|
1245
|
+
function stallNextInsert(matches: (options: CreateTaskOptions | undefined) => boolean) {
|
|
1246
|
+
const original = db.createTaskExtended;
|
|
1247
|
+
let reached: () => void = () => {};
|
|
1248
|
+
const started = new Promise<void>((resolve) => {
|
|
1249
|
+
reached = resolve;
|
|
1250
|
+
});
|
|
1251
|
+
let armed = true;
|
|
1252
|
+
const spy = spyOn(db, "createTaskExtended").mockImplementation(async (task, options) => {
|
|
1253
|
+
if (armed && matches(options)) {
|
|
1254
|
+
armed = false;
|
|
1255
|
+
reached();
|
|
1256
|
+
await Bun.sleep(300);
|
|
1257
|
+
}
|
|
1258
|
+
return original(task, options);
|
|
1259
|
+
});
|
|
1260
|
+
return { started, restore: () => spy.mockRestore() };
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const isStep = (options: CreateTaskOptions | undefined) =>
|
|
1264
|
+
options?.taskType === "script-run-step";
|
|
1265
|
+
|
|
1266
|
+
test("a step racing a blocked sibling of its run: the run dispatches all or none", async () => {
|
|
1267
|
+
const matching = "rebuild the hiring pipeline dashboard from scratch";
|
|
1268
|
+
await completedWork("C_RUN_PAR", "10.1", matching);
|
|
1269
|
+
const followUp = await session("C_RUN_PAR", "10.1");
|
|
1270
|
+
const runId = await launch(followUp.id);
|
|
1271
|
+
|
|
1272
|
+
// The distinct step decides first and is mid-insert when the matching one arrives.
|
|
1273
|
+
const stall = stallNextInsert(isStep);
|
|
1274
|
+
try {
|
|
1275
|
+
const distinct = step(runId, "distinct", randomText());
|
|
1276
|
+
await stall.started;
|
|
1277
|
+
const repeat = step(runId, "repeat", matching);
|
|
1278
|
+
expect([(await distinct).status, (await repeat).status]).toEqual([200, 200]);
|
|
1279
|
+
} finally {
|
|
1280
|
+
stall.restore();
|
|
1281
|
+
}
|
|
1282
|
+
}, 30_000);
|
|
1283
|
+
|
|
1284
|
+
test("a stepKey racing its own first dispatch replays the same task", async () => {
|
|
1285
|
+
const followUp = await session("C_RUN_SAMEKEY", "15.1");
|
|
1286
|
+
const runId = await launch(followUp.id);
|
|
1287
|
+
const text = randomText();
|
|
1288
|
+
|
|
1289
|
+
// Both calls miss the replay lookup; the first then waits before its insert.
|
|
1290
|
+
const original = modelValidation.explicitModelErrorForAgent;
|
|
1291
|
+
let reached: () => void = () => {};
|
|
1292
|
+
const started = new Promise<void>((resolve) => {
|
|
1293
|
+
reached = resolve;
|
|
1294
|
+
});
|
|
1295
|
+
let calls = 0;
|
|
1296
|
+
const spy = spyOn(modelValidation, "explicitModelErrorForAgent").mockImplementation(
|
|
1297
|
+
async (input) => {
|
|
1298
|
+
if (calls++ === 0) {
|
|
1299
|
+
reached();
|
|
1300
|
+
await Bun.sleep(300);
|
|
1301
|
+
}
|
|
1302
|
+
return original(input);
|
|
1303
|
+
},
|
|
1304
|
+
);
|
|
1305
|
+
let responses: TestResponse[];
|
|
1306
|
+
try {
|
|
1307
|
+
const first = step(runId, "once", text);
|
|
1308
|
+
await started;
|
|
1309
|
+
const second = step(runId, "once", text);
|
|
1310
|
+
responses = [await first, await second];
|
|
1311
|
+
} finally {
|
|
1312
|
+
spy.mockRestore();
|
|
1313
|
+
}
|
|
1314
|
+
expect(responses.map((r) => r.status)).toEqual([200, 200]);
|
|
1315
|
+
const ids = await Promise.all(
|
|
1316
|
+
responses.map(async (r) => ((await r.json()) as { taskId: string }).taskId),
|
|
1317
|
+
);
|
|
1318
|
+
expect(ids[0]).toBe(ids[1]);
|
|
1319
|
+
const rows = await getDbClient().get<{ c: number }>(
|
|
1320
|
+
"SELECT COUNT(*) AS c FROM agent_tasks WHERE contextKey = ?",
|
|
1321
|
+
[`script-run:${runId}:once`],
|
|
1322
|
+
);
|
|
1323
|
+
expect(rows?.c).toBe(1);
|
|
1324
|
+
}, 30_000);
|
|
1325
|
+
|
|
1326
|
+
test("a blocked first step blocks the run's later steps until one overrides", async () => {
|
|
1327
|
+
const matching = "migrate the billing webhooks to the new endpoint";
|
|
1328
|
+
await completedWork("C_RUN_STICKY", "11.1", matching);
|
|
1329
|
+
const followUp = await session("C_RUN_STICKY", "11.1");
|
|
1330
|
+
const runId = await launch(followUp.id);
|
|
1331
|
+
|
|
1332
|
+
expect((await step(runId, "repeat", matching)).status).toBe(422);
|
|
1333
|
+
const later = await step(runId, "distinct", randomText());
|
|
1334
|
+
expect(later.status).toBe(422);
|
|
1335
|
+
expect(((await later.json()) as { rule: string }).rule).toBe("same-work");
|
|
1336
|
+
|
|
1337
|
+
const overridden = await step(runId, "repeat", matching, { allowRedelegation: true });
|
|
1338
|
+
expect(overridden.status).toBe(200);
|
|
1339
|
+
expect((await step(runId, "after-override", randomText())).status).toBe(200);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
test("two runs racing at the cap minus one: exactly one dispatches", async () => {
|
|
1343
|
+
await sendTaskDispatches("C_RUN_RACE", "12.1", FOLLOW_UP_DISPATCH_CAP - 1);
|
|
1344
|
+
const followUp = await session("C_RUN_RACE", "12.1");
|
|
1345
|
+
const [a, b] = [await launch(followUp.id), await launch(followUp.id)];
|
|
1346
|
+
|
|
1347
|
+
const stall = stallNextInsert(isStep);
|
|
1348
|
+
try {
|
|
1349
|
+
const first = step(a, "go", randomText());
|
|
1350
|
+
await stall.started;
|
|
1351
|
+
const second = step(b, "go", randomText());
|
|
1352
|
+
expect([(await first).status, (await second).status]).toEqual([200, 422]);
|
|
1353
|
+
} finally {
|
|
1354
|
+
stall.restore();
|
|
1355
|
+
}
|
|
1356
|
+
expect(await dispatchCount("C_RUN_RACE", "12.1")).toBe(FOLLOW_UP_DISPATCH_CAP);
|
|
1357
|
+
}, 30_000);
|
|
1358
|
+
|
|
1359
|
+
test("a run and a send-task racing at the cap minus one: exactly one dispatches, in either order", async () => {
|
|
1360
|
+
for (const [i, runFirst] of [true, false].entries()) {
|
|
1361
|
+
const thread = `13.${i}`;
|
|
1362
|
+
await sendTaskDispatches("C_RUN_MIX_RACE", thread, FOLLOW_UP_DISPATCH_CAP - 1);
|
|
1363
|
+
const followUp = await session("C_RUN_MIX_RACE", thread);
|
|
1364
|
+
const runId = await launch(followUp.id);
|
|
1365
|
+
const runCall = async () => (await step(runId, "go", randomText())).status === 200;
|
|
1366
|
+
const sendCall = async () => (await sendTask(followUp.id, randomText())).ok;
|
|
1367
|
+
|
|
1368
|
+
// Whichever dispatcher goes first is mid-insert when the other arrives.
|
|
1369
|
+
const stall = stallNextInsert((options) => isStep(options) === runFirst);
|
|
1370
|
+
try {
|
|
1371
|
+
const first = runFirst ? runCall() : sendCall();
|
|
1372
|
+
await stall.started;
|
|
1373
|
+
const second = runFirst ? sendCall() : runCall();
|
|
1374
|
+
expect([await first, await second]).toEqual([true, false]);
|
|
1375
|
+
} finally {
|
|
1376
|
+
stall.restore();
|
|
1377
|
+
}
|
|
1378
|
+
expect(await dispatchCount("C_RUN_MIX_RACE", thread)).toBe(FOLLOW_UP_DISPATCH_CAP);
|
|
1379
|
+
}
|
|
1380
|
+
}, 30_000);
|
|
1381
|
+
|
|
1382
|
+
test("a long run is dated by its first step, not by steps it adds later", async () => {
|
|
1383
|
+
await sendTaskDispatches("C_RUN_WINDOW", "14.1", FOLLOW_UP_DISPATCH_CAP - 1);
|
|
1384
|
+
const followUp = await session("C_RUN_WINDOW", "14.1");
|
|
1385
|
+
const longRun = await launch(followUp.id);
|
|
1386
|
+
expect((await step(longRun, "first", randomText())).status).toBe(200);
|
|
1387
|
+
const firstStep = await getLatestScriptRunStepTaskByContextKey(`script-run:${longRun}:first`);
|
|
1388
|
+
await backdate(firstStep!.id, FOLLOW_UP_DISPATCH_WINDOW_MINUTES + 60);
|
|
1389
|
+
expect(await dispatchCount("C_RUN_WINDOW", "14.1")).toBe(FOLLOW_UP_DISPATCH_CAP - 1);
|
|
1390
|
+
|
|
1391
|
+
// A step created now does not move the run into the window.
|
|
1392
|
+
expect((await step(longRun, "later", randomText())).status).toBe(200);
|
|
1393
|
+
expect(await dispatchCount("C_RUN_WINDOW", "14.1")).toBe(FOLLOW_UP_DISPATCH_CAP - 1);
|
|
1394
|
+
|
|
1395
|
+
// A run whose first step is inside the window counts once.
|
|
1396
|
+
const freshRun = await launch(followUp.id);
|
|
1397
|
+
expect((await step(freshRun, "first", randomText())).status).toBe(200);
|
|
1398
|
+
expect((await step(freshRun, "second", randomText())).status).toBe(200);
|
|
1399
|
+
expect(await dispatchCount("C_RUN_WINDOW", "14.1")).toBe(FOLLOW_UP_DISPATCH_CAP);
|
|
1400
|
+
});
|
|
1401
|
+
});
|
|
1402
|
+
|
|
721
1403
|
describe("durable launch source with an embedded secret", () => {
|
|
722
1404
|
test("is refused before the run row is written", async () => {
|
|
723
1405
|
const value = `durable-${randomToken(32)}`;
|