@desplega.ai/agent-swarm 2.5.2 → 2.5.4
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-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
- package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
- package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
- package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
- package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
- package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
- package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
- package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
- package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
- package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
- package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
- package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
- package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
- package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
- package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
- package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
- package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
- package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
- package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
- package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
- package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
- package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
- package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
- package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
- package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
- package/openapi.json +3078 -2424
- package/package.json +7 -7
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +366 -8
- package/src/be/db.ts +92 -22
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +70 -53
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/sessions.ts +27 -2
- package/src/http/workflows.ts +129 -24
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +205 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/types.ts +7 -0
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +15 -4
- package/src/workflows/checkpoint.ts +72 -35
- package/src/workflows/engine.ts +262 -93
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +68 -1
- package/src/workflows/resume.ts +228 -77
- package/src/workflows/retry-poller.ts +74 -7
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
|
@@ -22,9 +22,12 @@ import * as dbClient from "../be/db-client";
|
|
|
22
22
|
import type { InstallExtensionArgs as ExtensionInstallBody } from "../be/extensions/db";
|
|
23
23
|
import { installExtension, listExtensionRuns } from "../be/extensions/db";
|
|
24
24
|
import { getCatalogEntry } from "../extensions/catalog";
|
|
25
|
+
import type { TaskBlockedEvent } from "../extensions/contract";
|
|
25
26
|
import { disableExtension, enableExtension, stopExtensionRuntime } from "../extensions/lifecycle";
|
|
27
|
+
import { initExtensionPostBridge, teardownExtensionPostBridge } from "../extensions/post-bridge";
|
|
26
28
|
import { handleTasks } from "../http/tasks";
|
|
27
29
|
import { dispatchScheduleTarget } from "../scheduler/scheduler";
|
|
30
|
+
import { bufferThreadMessage, instantFlush } from "../slack/thread-buffer";
|
|
28
31
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
29
32
|
import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
|
|
30
33
|
import { registerSendTaskTool, sendTaskHandler } from "../tools/send-task";
|
|
@@ -145,6 +148,276 @@ describe("extension task boundaries", () => {
|
|
|
145
148
|
await client.run("DELETE FROM agents");
|
|
146
149
|
});
|
|
147
150
|
|
|
151
|
+
describe("post.task.blocked", () => {
|
|
152
|
+
const events: unknown[] = [];
|
|
153
|
+
const capture = (event: unknown) => {
|
|
154
|
+
events.push(event);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
beforeAll(() => {
|
|
158
|
+
initExtensionPostBridge();
|
|
159
|
+
workflowEventBus.on("task.create_blocked", capture);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
afterAll(() => {
|
|
163
|
+
workflowEventBus.off("task.create_blocked", capture);
|
|
164
|
+
teardownExtensionPostBridge();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
events.length = 0;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
async function waitForCounter(
|
|
172
|
+
extension: Awaited<ReturnType<typeof installAndEnable>>,
|
|
173
|
+
key: string,
|
|
174
|
+
count = 1,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const deadline = Date.now() + 3_000;
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
const entry = await getKv(`task:agent:${extension.agentId}`, key);
|
|
179
|
+
if (entry?.value === count) return;
|
|
180
|
+
await Bun.sleep(10);
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`post.task.blocked was not recorded ${count} time(s)`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function recordedBlock(
|
|
186
|
+
extension: Awaited<ReturnType<typeof installAndEnable>>,
|
|
187
|
+
count = 1,
|
|
188
|
+
): Promise<TaskBlockedEvent> {
|
|
189
|
+
await waitForCounter(extension, "ext:record-blocks:count", count);
|
|
190
|
+
return (await getKv(`task:agent:${extension.agentId}`, "ext:record-blocks:last"))
|
|
191
|
+
?.value as TaskBlockedEvent;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
test.each([
|
|
195
|
+
"rest",
|
|
196
|
+
"mcp",
|
|
197
|
+
"slack",
|
|
198
|
+
"schedule",
|
|
199
|
+
] as const)("%s delivers one blocked event with its source and requester", async (origin) => {
|
|
200
|
+
const blocker = await installAndEnable("block-tasks-from-source", { source: origin });
|
|
201
|
+
const recorder = await installAndEnable("record-blocks");
|
|
202
|
+
let requester: TaskBlockedEvent["requester"] = {};
|
|
203
|
+
let scheduleId: string | undefined;
|
|
204
|
+
|
|
205
|
+
if (origin === "rest") {
|
|
206
|
+
const user = await createUser({ name: "Blocked REST requester" });
|
|
207
|
+
requester = { userId: user.id };
|
|
208
|
+
expect(
|
|
209
|
+
(await postTask({ task: "blocked REST task", requestedByUserId: user.id })).status,
|
|
210
|
+
).toBe(422);
|
|
211
|
+
} else if (origin === "mcp") {
|
|
212
|
+
const caller = await createAgent({ name: "blocked-mcp-caller", status: "idle" });
|
|
213
|
+
requester = { agentId: caller.id };
|
|
214
|
+
const server = new McpServer({ name: "blocked-event-send", version: "1.0.0" });
|
|
215
|
+
registerSendTaskTool(server);
|
|
216
|
+
const result = await registeredSendTask(server).handler(sendTaskArgs("blocked MCP task"), {
|
|
217
|
+
sessionId: "blocked-event",
|
|
218
|
+
requestInfo: { headers: { "x-agent-id": caller.id } },
|
|
219
|
+
});
|
|
220
|
+
expect(result.isError).toBe(true);
|
|
221
|
+
} else if (origin === "slack") {
|
|
222
|
+
requester = { slackUserId: "U_BLOCKED_EVENT" };
|
|
223
|
+
await createAgent({ name: "blocked-slack-lead", isLead: true, status: "idle" });
|
|
224
|
+
bufferThreadMessage(
|
|
225
|
+
"C_BLOCKED_EVENT",
|
|
226
|
+
"1000.0001",
|
|
227
|
+
"blocked Slack task",
|
|
228
|
+
"U_BLOCKED_EVENT",
|
|
229
|
+
"1000.0002",
|
|
230
|
+
);
|
|
231
|
+
await instantFlush("C_BLOCKED_EVENT:1000.0001");
|
|
232
|
+
} else {
|
|
233
|
+
const schedule = await createScheduledTask({
|
|
234
|
+
name: "blocked-event-schedule",
|
|
235
|
+
taskTemplate: "blocked schedule task",
|
|
236
|
+
intervalMs: 60_000,
|
|
237
|
+
});
|
|
238
|
+
scheduleId = schedule.id;
|
|
239
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow(
|
|
240
|
+
"Task source schedule is blocked",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const event = await recordedBlock(recorder);
|
|
245
|
+
expect(events).toHaveLength(1);
|
|
246
|
+
expect(event).toMatchObject({
|
|
247
|
+
extension: { id: blocker.id, name: "block-tasks-from-source" },
|
|
248
|
+
origin,
|
|
249
|
+
source: origin === "rest" ? "api" : origin,
|
|
250
|
+
requester,
|
|
251
|
+
});
|
|
252
|
+
expect(event.requester).toEqual(requester);
|
|
253
|
+
if (scheduleId) expect(event.context?.scheduleId).toBe(scheduleId);
|
|
254
|
+
expect(event).not.toHaveProperty("task");
|
|
255
|
+
expect(event).not.toHaveProperty("taskId");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("scrubs and bounds the payload, preserving correlation on each retry", async () => {
|
|
259
|
+
const secret = `ghp_${"Z".repeat(36)}`;
|
|
260
|
+
await installSource(
|
|
261
|
+
"secret-blocker",
|
|
262
|
+
`
|
|
263
|
+
import { block, type SwarmExtension } from "swarm-extension";
|
|
264
|
+
const extension: SwarmExtension = (api) => {
|
|
265
|
+
api.on("pre.task.create", () => block(${JSON.stringify(`Ticket required: ${secret}`)}));
|
|
266
|
+
};
|
|
267
|
+
export default extension;
|
|
268
|
+
`,
|
|
269
|
+
);
|
|
270
|
+
const recorder = await installAndEnable("record-blocks");
|
|
271
|
+
const context = {
|
|
272
|
+
contextKey: "github:acme/project:7",
|
|
273
|
+
scheduleId: crypto.randomUUID(),
|
|
274
|
+
workflowRunId: crypto.randomUUID(),
|
|
275
|
+
workflowRunStepId: crypto.randomUUID(),
|
|
276
|
+
parentTaskId: crypto.randomUUID(),
|
|
277
|
+
vcsRepo: "acme/project",
|
|
278
|
+
vcsNumber: 7,
|
|
279
|
+
slackChannelId: "C_CORRELATED_BLOCK",
|
|
280
|
+
slackThreadTs: "2000.0001",
|
|
281
|
+
};
|
|
282
|
+
const requester = {
|
|
283
|
+
userId: crypto.randomUUID(),
|
|
284
|
+
agentId: crypto.randomUUID(),
|
|
285
|
+
slackUserId: "U_CORRELATED_BLOCK",
|
|
286
|
+
};
|
|
287
|
+
for (const attempt of [1, 2]) {
|
|
288
|
+
await expect(
|
|
289
|
+
createTaskWithSiblingAwareness(
|
|
290
|
+
`${"x".repeat(480)} ${secret}`,
|
|
291
|
+
{
|
|
292
|
+
...context,
|
|
293
|
+
source: "github",
|
|
294
|
+
requestedByUserId: requester.userId,
|
|
295
|
+
creatorAgentId: requester.agentId,
|
|
296
|
+
slackUserId: requester.slackUserId,
|
|
297
|
+
},
|
|
298
|
+
{ origin: "webhook" },
|
|
299
|
+
),
|
|
300
|
+
).rejects.toThrow("Ticket required:");
|
|
301
|
+
const event = await recordedBlock(recorder, attempt);
|
|
302
|
+
expect(events).toHaveLength(attempt);
|
|
303
|
+
expect(event.reason).toContain("[REDACTED:");
|
|
304
|
+
expect(event.reason).not.toContain(secret);
|
|
305
|
+
expect(event.description).toHaveLength(500);
|
|
306
|
+
expect(event.description).not.toContain("ghp_");
|
|
307
|
+
expect(event.context).toEqual(context);
|
|
308
|
+
expect(event.requester).toEqual(requester);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a rejected model rewrite emits a block attributed to its editor", async () => {
|
|
313
|
+
const codex = await createAgent({
|
|
314
|
+
name: "blocked-rewrite-codex",
|
|
315
|
+
harnessProvider: "codex",
|
|
316
|
+
status: "idle",
|
|
317
|
+
});
|
|
318
|
+
const editor = await installSource(
|
|
319
|
+
"incompatible-model",
|
|
320
|
+
`
|
|
321
|
+
import { modify, type SwarmExtension } from "swarm-extension";
|
|
322
|
+
const extension: SwarmExtension = (api) => {
|
|
323
|
+
api.on("pre.task.create", () => modify({ model: "claude-opus-5-5" }));
|
|
324
|
+
};
|
|
325
|
+
export default extension;
|
|
326
|
+
`,
|
|
327
|
+
);
|
|
328
|
+
const recorder = await installAndEnable("record-blocks");
|
|
329
|
+
const result = await postTask({
|
|
330
|
+
task: "incompatible rewrite",
|
|
331
|
+
agentId: codex.id,
|
|
332
|
+
routingReason: "human_pinned",
|
|
333
|
+
});
|
|
334
|
+
expect(result.status).toBe(422);
|
|
335
|
+
const event = await recordedBlock(recorder);
|
|
336
|
+
expect(events).toHaveLength(1);
|
|
337
|
+
expect(event.extension).toEqual({ id: editor.id, name: "incompatible-model" });
|
|
338
|
+
expect(event.reason).toContain("rewrite");
|
|
339
|
+
expect(event.reason).toContain("codex");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("the creator is skipped while the blocking extension receives its own block", async () => {
|
|
343
|
+
const creator = await installAndEnable("record-blocks");
|
|
344
|
+
const blocker = await installSource(
|
|
345
|
+
"blocking-recorder",
|
|
346
|
+
`
|
|
347
|
+
import { block, type SwarmExtension } from "swarm-extension";
|
|
348
|
+
const extension: SwarmExtension = (api) => {
|
|
349
|
+
api.on("pre.task.create", () => block("blocked by recorder"));
|
|
350
|
+
api.on("post.task.blocked", async (_event, ctx) => { await ctx.state.incr("count"); });
|
|
351
|
+
};
|
|
352
|
+
export default extension;
|
|
353
|
+
`,
|
|
354
|
+
);
|
|
355
|
+
const observerBundle = await loadBundleFixture("record-blocks");
|
|
356
|
+
const installedObserver = await installExtension({
|
|
357
|
+
...observerBundle,
|
|
358
|
+
manifest: { ...observerBundle.manifest, name: "last-block-observer" },
|
|
359
|
+
priority: 1_000,
|
|
360
|
+
});
|
|
361
|
+
const observer = await enableExtension(installedObserver.extension.id);
|
|
362
|
+
const server = new McpServer({ name: "extension-blocked-event", version: "1.0.0" });
|
|
363
|
+
registerSendTaskTool(server);
|
|
364
|
+
const result = await registeredSendTask(server).handler(
|
|
365
|
+
sendTaskArgs("extension task"),
|
|
366
|
+
markExtensionRequestOrigin({
|
|
367
|
+
sessionId: "blocked-extension",
|
|
368
|
+
requestInfo: { headers: { "x-agent-id": creator.agentId! } },
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
expect(result.isError).toBe(true);
|
|
372
|
+
// The final subscriber acknowledges only after every earlier recipient has finished.
|
|
373
|
+
await waitForCounter(observer, "ext:last-block-observer:count");
|
|
374
|
+
expect(
|
|
375
|
+
(await getKv(`task:agent:${blocker.agentId}`, "ext:blocking-recorder:count"))?.value,
|
|
376
|
+
).toBe(1);
|
|
377
|
+
expect(events).toHaveLength(1);
|
|
378
|
+
expect(events[0]).toMatchObject({ origin: "extension:record-blocks" });
|
|
379
|
+
expect(await getKv(`task:agent:${creator.agentId}`, "ext:record-blocks:count")).toBeNull();
|
|
380
|
+
expect(await listExtensionRuns(creator.id)).toEqual([]);
|
|
381
|
+
const runs = await listExtensionRuns(blocker.id);
|
|
382
|
+
expect(runs).toContainEqual(
|
|
383
|
+
expect.objectContaining({
|
|
384
|
+
event: "post.task.blocked",
|
|
385
|
+
agentId: creator.agentId,
|
|
386
|
+
subject: "extension:record-blocks: blocking-recorder",
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("continue and valid rewrites emit no blocked event", async () => {
|
|
392
|
+
const recorder = await installAndEnable("record-blocks");
|
|
393
|
+
expect((await postTask({ task: "continued task" })).status).toBe(201);
|
|
394
|
+
await installAndEnable("rewrite-task-priority");
|
|
395
|
+
const modified = await postTask({ task: "rewritten task" });
|
|
396
|
+
expect(modified.status).toBe(201);
|
|
397
|
+
expect(modified.body.priority).toBe(1);
|
|
398
|
+
expect(events).toEqual([]);
|
|
399
|
+
expect(await getKv(`task:agent:${recorder.agentId}`, "ext:record-blocks:count")).toBeNull();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("a throwing bus observer does not replace the blocked response", async () => {
|
|
403
|
+
await installAndEnable("block-tasks-from-source", { source: "rest" });
|
|
404
|
+
let calls = 0;
|
|
405
|
+
const observer = () => {
|
|
406
|
+
calls++;
|
|
407
|
+
throw new Error("broken block observer");
|
|
408
|
+
};
|
|
409
|
+
const error = spyOn(console, "error").mockImplementation(() => {});
|
|
410
|
+
workflowEventBus.on("task.create_blocked", observer);
|
|
411
|
+
try {
|
|
412
|
+
expect((await postTask({ task: "still blocked" })).status).toBe(422);
|
|
413
|
+
expect(calls).toBe(1);
|
|
414
|
+
} finally {
|
|
415
|
+
workflowEventBus.off("task.create_blocked", observer);
|
|
416
|
+
error.mockRestore();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
148
421
|
test("REST task creation applies rewrites and reports blocks as 422", async () => {
|
|
149
422
|
const rewrite = await installAndEnable("rewrite-task-priority");
|
|
150
423
|
const created = await postTask({ task: "rewrite this task" });
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
fetchResolvedEnv,
|
|
15
15
|
RELOADABLE_ENV_KEYS,
|
|
16
16
|
} from "../commands/runner";
|
|
17
|
+
import { contextBoundNoteThreshold } from "../hooks/context-bound-note";
|
|
17
18
|
import { checkCodexCredentials } from "../providers/codex-adapter";
|
|
18
19
|
import { MASKED_SECRET_VALUE } from "../utils/config-secrets";
|
|
19
20
|
import { registerVolatileSecret } from "../utils/secret-scrubber";
|
|
@@ -795,6 +796,22 @@ describe("agent-scoped PI_CODEMODE_MODELS reaches the worker env", () => {
|
|
|
795
796
|
});
|
|
796
797
|
});
|
|
797
798
|
|
|
799
|
+
describe("agent-scoped CONTEXT_BOUND_NOTE_TOKENS turns the note on for one agent", () => {
|
|
800
|
+
// Each session's env is this agent's /api/config/resolved result (global,
|
|
801
|
+
// then agent, then repo rows), and every adapter reads the threshold from it.
|
|
802
|
+
test("the agent with the row gets the threshold; an agent without it stays off", async () => {
|
|
803
|
+
mockResponsesByAgentId.set("bound-note-pilot", {
|
|
804
|
+
status: 200,
|
|
805
|
+
body: { configs: [{ key: "CONTEXT_BOUND_NOTE_TOKENS", value: "150000" }] },
|
|
806
|
+
});
|
|
807
|
+
const baseEnv: Record<string, string | undefined> = {};
|
|
808
|
+
const pilot = await fetchResolvedEnv(testUrl, "key", "bound-note-pilot", baseEnv);
|
|
809
|
+
const other = await fetchResolvedEnv(testUrl, "key", "bound-note-other", baseEnv);
|
|
810
|
+
expect(contextBoundNoteThreshold(pilot.env)).toBe(150_000);
|
|
811
|
+
expect(contextBoundNoteThreshold(other.env)).toBe(0);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
798
815
|
describe("deleting a reloadable config row", () => {
|
|
799
816
|
// One key per test: the runner remembers each overridden key's boot value
|
|
800
817
|
// for the life of the process.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SwarmExtension } from "swarm-extension";
|
|
2
|
+
|
|
3
|
+
const extension: SwarmExtension = (api) => {
|
|
4
|
+
api.on("post.task.blocked", async (event, ctx) => {
|
|
5
|
+
await ctx.state.set("last", event);
|
|
6
|
+
await ctx.state.incr("count");
|
|
7
|
+
});
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default extension;
|
|
@@ -104,12 +104,19 @@ async function main() {
|
|
|
104
104
|
);
|
|
105
105
|
|
|
106
106
|
const sessionId = "T-fake-session";
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
// `AMP_TEST_REQUEST_USAGE`: "missing" drops per-request usage, "corrupt" sends non-numbers.
|
|
108
|
+
const requestUsage = process.env.AMP_TEST_REQUEST_USAGE;
|
|
109
|
+
const usage =
|
|
110
|
+
requestUsage === "missing"
|
|
111
|
+
? undefined
|
|
112
|
+
: requestUsage === "corrupt"
|
|
113
|
+
? { input_tokens: "lots", cache_creation_input_tokens: null, cache_read_input_tokens: {} }
|
|
114
|
+
: {
|
|
115
|
+
input_tokens: 4,
|
|
116
|
+
output_tokens: 5,
|
|
117
|
+
cache_creation_input_tokens: 1000,
|
|
118
|
+
cache_read_input_tokens: 200,
|
|
119
|
+
};
|
|
113
120
|
// Amp runs shell commands in a session of its own, outside the process group.
|
|
114
121
|
// Return only once the child has left: a group kill that lands before its
|
|
115
122
|
// setsid() would take it down and hide a leak.
|
|
@@ -217,8 +224,13 @@ async function main() {
|
|
|
217
224
|
session_id: sessionId,
|
|
218
225
|
});
|
|
219
226
|
turns += 1;
|
|
220
|
-
const steerTool = mode === "steer" || mode.startsWith("steer-crash");
|
|
221
|
-
|
|
227
|
+
const steerTool = mode === "steer" || mode === "steer-twice" || mode.startsWith("steer-crash");
|
|
228
|
+
const toolCalls = mode === "steer-twice" ? 2 : 1;
|
|
229
|
+
for (
|
|
230
|
+
let call = 0;
|
|
231
|
+
call < toolCalls && (mode === "tool" || (steerTool && index === 0));
|
|
232
|
+
call++
|
|
233
|
+
) {
|
|
222
234
|
assistant(
|
|
223
235
|
[{ type: "tool_use", id: "TU-1", name: "code_exec", input: { code: "1+1" } }],
|
|
224
236
|
"tool_use",
|
|
@@ -261,6 +273,8 @@ async function main() {
|
|
|
261
273
|
if (mode === "success-child") await startDetachedChild();
|
|
262
274
|
// A turn that thinks for a while with no tool call.
|
|
263
275
|
if (mode === "steer-no-tool") await Bun.sleep(600);
|
|
276
|
+
// `AMP_TEST_POST_RESULT_DELAY_MS`: the final reply takes this long after the last tool result.
|
|
277
|
+
await Bun.sleep(Number(process.env.AMP_TEST_POST_RESULT_DELAY_MS) || 0);
|
|
264
278
|
lastText = mode.startsWith("steer") ? `DONE${index + 1}` : "Done ✓";
|
|
265
279
|
assistant([{ type: "text", text: lastText }], "end_turn");
|
|
266
280
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { configureHttpResolver, resetHttpResolver } from "../../prompts/resolver";
|
|
2
|
+
|
|
3
|
+
export const HELD_NOTE_TEXT = "Finish current work and complete or defer.";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Point the prompt resolver at a local server whose template responses wait
|
|
7
|
+
* for `release()`, so a test can choose when an async note render finishes.
|
|
8
|
+
* `requested` resolves on the first render request. `stop()` releases,
|
|
9
|
+
* restores the resolver and closes the server.
|
|
10
|
+
*/
|
|
11
|
+
export function holdTemplateRender(): {
|
|
12
|
+
release: () => void;
|
|
13
|
+
requested: Promise<void>;
|
|
14
|
+
stop: () => void;
|
|
15
|
+
} {
|
|
16
|
+
let release!: () => void;
|
|
17
|
+
const gate = new Promise<void>((resolve) => {
|
|
18
|
+
release = resolve;
|
|
19
|
+
});
|
|
20
|
+
let onRequest!: () => void;
|
|
21
|
+
const requested = new Promise<void>((resolve) => {
|
|
22
|
+
onRequest = resolve;
|
|
23
|
+
});
|
|
24
|
+
const server = Bun.serve({
|
|
25
|
+
port: 0,
|
|
26
|
+
async fetch() {
|
|
27
|
+
onRequest();
|
|
28
|
+
await gate;
|
|
29
|
+
return Response.json({ text: HELD_NOTE_TEXT, skipped: false, unresolved: [] });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
configureHttpResolver(`http://localhost:${server.port}`, "test");
|
|
33
|
+
return {
|
|
34
|
+
release,
|
|
35
|
+
requested,
|
|
36
|
+
stop: () => {
|
|
37
|
+
release();
|
|
38
|
+
resetHttpResolver();
|
|
39
|
+
server.stop(true);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One keyed workflow trigger on its own DB connection, for
|
|
3
|
+
* `workflow-idempotency.test.ts`. Runs the HTTP handler's sequence: look the
|
|
4
|
+
* key up, then start the run with the key recorded in the run's transaction,
|
|
5
|
+
* and answer from the record when a concurrent process took the key first.
|
|
6
|
+
*
|
|
7
|
+
* Barrier: after the lookup finds nothing it creates `<dir>/looked-<n>` and
|
|
8
|
+
* waits for `<dir>/go`, so the parent can release every child only once all
|
|
9
|
+
* of them are past the lookup and about to race for the write lock.
|
|
10
|
+
*
|
|
11
|
+
* Prints one JSON line: `{ runId, replayed }`.
|
|
12
|
+
*/
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { getWorkflow, initDb } from "../../be/db";
|
|
15
|
+
import {
|
|
16
|
+
findAcceptedWorkflowOperation,
|
|
17
|
+
IdempotencyKeyTakenError,
|
|
18
|
+
recordAcceptedWorkflowOperation,
|
|
19
|
+
type WorkflowIdempotencyClaim,
|
|
20
|
+
workflowRequestFingerprint,
|
|
21
|
+
} from "../../be/db/workflow-idempotency";
|
|
22
|
+
import { getExecutorRegistry, startWorkflowExecution } from "../../workflows";
|
|
23
|
+
|
|
24
|
+
const [dbPath, workflowId, key, barrierDir, n] = process.argv.slice(2);
|
|
25
|
+
if (!dbPath || !workflowId || !key || !barrierDir || !n) {
|
|
26
|
+
console.error("usage: workflow-idempotency-trigger.child <db> <workflowId> <key> <dir> <n>");
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realLog = console.log;
|
|
31
|
+
console.log = () => {};
|
|
32
|
+
console.debug = () => {};
|
|
33
|
+
console.warn = () => {};
|
|
34
|
+
|
|
35
|
+
initDb(dbPath);
|
|
36
|
+
const workflow = await getWorkflow(workflowId);
|
|
37
|
+
if (!workflow) throw new Error(`workflow ${workflowId} not found`);
|
|
38
|
+
|
|
39
|
+
const body = { n: 1 };
|
|
40
|
+
const claim: WorkflowIdempotencyClaim = {
|
|
41
|
+
principal: "agent:concurrency-test",
|
|
42
|
+
operation: "workflow.trigger",
|
|
43
|
+
targetId: workflowId,
|
|
44
|
+
key,
|
|
45
|
+
fingerprint: workflowRequestFingerprint("workflow.trigger", body),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (await findAcceptedWorkflowOperation(claim)) throw new Error("key already accepted");
|
|
49
|
+
await Bun.write(join(barrierDir, `looked-${n}`), "");
|
|
50
|
+
const go = Bun.file(join(barrierDir, "go"));
|
|
51
|
+
while (!(await go.exists())) await Bun.sleep(5);
|
|
52
|
+
|
|
53
|
+
let result: { runId: string; replayed: boolean };
|
|
54
|
+
try {
|
|
55
|
+
const runId = await startWorkflowExecution(workflow, body, getExecutorRegistry(), {
|
|
56
|
+
triggerType: "api",
|
|
57
|
+
accept: (run) =>
|
|
58
|
+
recordAcceptedWorkflowOperation(claim, {
|
|
59
|
+
workflowId,
|
|
60
|
+
runId: run.runId,
|
|
61
|
+
status: 201,
|
|
62
|
+
body: run,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
result = { runId, replayed: false };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (!(err instanceof IdempotencyKeyTakenError)) throw err;
|
|
68
|
+
const accepted = await findAcceptedWorkflowOperation(claim);
|
|
69
|
+
result = { runId: (accepted?.body as { runId: string }).runId, replayed: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
realLog(JSON.stringify(result));
|
|
73
|
+
process.exit(0);
|
|
@@ -266,6 +266,39 @@ describe("list-endpoint slimming", () => {
|
|
|
266
266
|
}
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
test("getAllTasks — the preview starts after a leading sibling block", async () => {
|
|
270
|
+
// `prependSiblingBlock` output: a long block, a blank line, then the ask.
|
|
271
|
+
const agentId = "preview-sibling-agent";
|
|
272
|
+
const sibling = (n: number) =>
|
|
273
|
+
`- [in_progress] task:${n} — agent:Picateclas — started 2m ago, last active 10s ago\n "${"é".repeat(190)}"`;
|
|
274
|
+
const block = `<sibling_tasks_in_progress>\nThe following tasks are already running in the same context (contextKey: slack:C1:1.2). The user has submitted new input while they were in flight — coordinate with them, do not duplicate:\n\n${[1, 2, 3].map(sibling).join("\n")}\n</sibling_tasks_in_progress>`;
|
|
275
|
+
const ask = `also bump the version ${"v".repeat(400)}`;
|
|
276
|
+
const texts = [
|
|
277
|
+
`${block}\n\n${ask}`,
|
|
278
|
+
`${block}\nno blank line`,
|
|
279
|
+
// No closing tag: nothing to skip.
|
|
280
|
+
`<sibling_tasks_in_progress>\n${"x".repeat(400)}`,
|
|
281
|
+
// Not at the start: nothing to skip.
|
|
282
|
+
`lead in\n\n${block}\n\nask`,
|
|
283
|
+
];
|
|
284
|
+
for (const text of texts) await createTaskExtended(text, { agentId });
|
|
285
|
+
|
|
286
|
+
const expected = [
|
|
287
|
+
`${ask.slice(0, 300)}…`,
|
|
288
|
+
"\nno blank line",
|
|
289
|
+
`<sibling_tasks_in_progress>\n${"x".repeat(272)}…`,
|
|
290
|
+
`${`lead in\n\n${block}\n\nask`.slice(0, 300)}…`,
|
|
291
|
+
].sort();
|
|
292
|
+
for (const opts of [{ slim: true }, { fields: "timeline" as const }]) {
|
|
293
|
+
const rows = await getAllTasks({ agentId, limit: 50 }, opts);
|
|
294
|
+
expect(rows.map((t) => t.task).sort()).toEqual(expected);
|
|
295
|
+
}
|
|
296
|
+
// Session roots map the full text, so the JS skip must agree with the SQL one.
|
|
297
|
+
const sessions = await listRecentSessions({ limit: 200, slim: true });
|
|
298
|
+
const roots = sessions.filter((s) => s.root.agentId === agentId).map((s) => s.root.task);
|
|
299
|
+
expect(roots.sort()).toEqual(expected);
|
|
300
|
+
});
|
|
301
|
+
|
|
269
302
|
test("getAllTasks — slim and full pages hold the same rows in the same order", async () => {
|
|
270
303
|
for (let i = 0; i < 12; i++) {
|
|
271
304
|
await createTaskExtended(`page-parity ${i}`, { agentId: "slim-agent-1" });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
test("migration 240 hands each legacy child to the attempt that owns it now", async () => {
|
|
5
|
+
const db = new Database(":memory:");
|
|
6
|
+
try {
|
|
7
|
+
db.exec("CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, workflowId TEXT, status TEXT)");
|
|
8
|
+
db.exec(
|
|
9
|
+
`CREATE TABLE workflow_run_steps (
|
|
10
|
+
id TEXT PRIMARY KEY, status TEXT, retryCount INTEGER NOT NULL DEFAULT 0, nextRetryAt TEXT
|
|
11
|
+
)`,
|
|
12
|
+
);
|
|
13
|
+
db.exec(await Bun.file("src/be/migrations/203_workflow_run_parent_step.sql").text());
|
|
14
|
+
const step = db.query(
|
|
15
|
+
"INSERT INTO workflow_run_steps (id, status, retryCount, nextRetryAt) VALUES (?, ?, ?, ?)",
|
|
16
|
+
);
|
|
17
|
+
// Created on attempt 1 (attempt 0 failed before any child existed) and parked.
|
|
18
|
+
step.run("parked-retry", "waiting", 1, null);
|
|
19
|
+
// Its child failed; attempt 2 is queued and must start a new child.
|
|
20
|
+
step.run("queued-retry", "failed", 2, "2030-01-01T00:00:00.000Z");
|
|
21
|
+
step.run("first-attempt", "completed", 0, null);
|
|
22
|
+
step.run("exhausted", "failed", 3, null);
|
|
23
|
+
const run = db.query(
|
|
24
|
+
"INSERT INTO workflow_runs (id, workflowId, status, parentStepId) VALUES (?, 'wf', ?, ?)",
|
|
25
|
+
);
|
|
26
|
+
run.run("child-parked", "waiting", "parked-retry");
|
|
27
|
+
run.run("child-queued", "failed", "queued-retry");
|
|
28
|
+
run.run("child-first", "completed", "first-attempt");
|
|
29
|
+
run.run("child-exhausted", "failed", "exhausted");
|
|
30
|
+
run.run("child-orphan", "completed", "missing-step");
|
|
31
|
+
run.run("top-level", "completed", null);
|
|
32
|
+
|
|
33
|
+
db.exec(await Bun.file("src/be/migrations/240_workflow_run_parent_step_attempt.sql").text());
|
|
34
|
+
|
|
35
|
+
expect(
|
|
36
|
+
db.prepare("SELECT id, parentStepAttempt AS attempt FROM workflow_runs ORDER BY id").all(),
|
|
37
|
+
).toEqual([
|
|
38
|
+
{ id: "child-exhausted", attempt: 3 },
|
|
39
|
+
{ id: "child-first", attempt: 0 },
|
|
40
|
+
{ id: "child-orphan", attempt: 0 },
|
|
41
|
+
{ id: "child-parked", attempt: 1 },
|
|
42
|
+
{ id: "child-queued", attempt: 1 },
|
|
43
|
+
{ id: "top-level", attempt: 0 },
|
|
44
|
+
]);
|
|
45
|
+
// One child per attempt: a second child for the parked attempt is rejected,
|
|
46
|
+
// one for the queued attempt is the new invocation.
|
|
47
|
+
const insert = db.query(
|
|
48
|
+
"INSERT INTO workflow_runs (id, workflowId, status, parentStepId, parentStepAttempt) VALUES (?, 'wf', 'running', ?, ?)",
|
|
49
|
+
);
|
|
50
|
+
expect(() => insert.run("dup", "parked-retry", 1)).toThrow();
|
|
51
|
+
insert.run("next", "queued-retry", 2);
|
|
52
|
+
} finally {
|
|
53
|
+
db.close();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
@@ -75,6 +75,8 @@ describe("runtime identity header in OpenAPI", () => {
|
|
|
75
75
|
"GET /api/poll",
|
|
76
76
|
"POST /api/mcp-bridge",
|
|
77
77
|
"POST /api/scripts/run",
|
|
78
|
+
"POST /api/workflows",
|
|
79
|
+
"POST /api/workflows/{id}/trigger",
|
|
78
80
|
"POST /close",
|
|
79
81
|
"POST /ping",
|
|
80
82
|
"PUT /api/agents/{id}/credential-status",
|