@desplega.ai/agent-swarm 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/{acp-adapter-dgfakfpw.js → acp-adapter-ma1js5n7.js} +6 -6
- package/dist/{actions-fbqma8aa.js → actions-1vhmtvg9.js} +18 -18
- package/dist/{amp-adapter-en0jy6hs.js → amp-adapter-sxqqvmdk.js} +3 -3
- package/dist/{app-k8a5cgpp.js → app-mwx1d5a9.js} +3 -3
- package/dist/{assistant-a16433gr.js → assistant-yx1p6g82.js} +20 -20
- package/dist/{boot-reembed-f4nn561n.js → boot-reembed-5h37a2c2.js} +1 -1
- package/dist/{boot-reembed-6c1sjfnr.js → boot-reembed-7h78vmvv.js} +2 -1
- package/dist/{boot-scrub-sweep-d4w9gajf.js → boot-scrub-sweep-8mawd4gw.js} +3 -3
- package/dist/{boot-seeds-9bmkspyw.js → boot-seeds-ca84dhnn.js} +4 -4
- package/dist/{claude-adapter-nys1kjxa.js → claude-adapter-wqt4hfkq.js} +7 -7
- package/dist/{claude-managed-adapter-55207973.js → claude-managed-adapter-6fjqtr2z.js} +2 -2
- package/dist/{claude-managed-setup-3cm35gey.js → claude-managed-setup-rqv98sea.js} +2 -2
- package/dist/{claude-sdk-session-nw5yd8cp.js → claude-sdk-session-f29f3b2x.js} +7 -7
- package/dist/{cli-sxgtf5fw.js → cli-07q03xm2.js} +1 -1
- package/dist/{cli-k21gx5ht.js → cli-1hhbzkbb.js} +6 -6
- package/dist/{cli-v49fkghp.js → cli-1m9x8dr5.js} +1 -1
- package/dist/{cli-24j1jeeb.js → cli-2pvzmas1.js} +3 -3
- package/dist/{cli-w2656ka1.js → cli-2y7v133v.js} +7 -7
- package/dist/{cli-g8ka5j4j.js → cli-48gxjmcz.js} +94 -36
- package/dist/{cli-2sjhpsns.js → cli-54sj64fc.js} +1 -1
- package/dist/{cli-aj8djyqt.js → cli-60sty27p.js} +2 -2
- package/dist/{cli-4hxrf0p4.js → cli-6a9qfa8b.js} +308 -47
- package/dist/{cli-n7asmce6.js → cli-6mgdba42.js} +3 -3
- package/dist/{cli-gf95q3bm.js → cli-6srv653c.js} +3 -3
- package/dist/{cli-9abdgn5t.js → cli-86ehn4s5.js} +3 -3
- package/dist/{cli-bqztfyve.js → cli-8mbc9za9.js} +1 -1
- package/dist/{cli-a2rr0fmg.js → cli-8wmj18cz.js} +1 -1
- package/dist/{cli-c2habvsf.js → cli-9gsm2rzp.js} +8 -8
- package/dist/{cli-4rf0f5vg.js → cli-9pshx6rd.js} +4 -2
- package/dist/{cli-abkm71vr.js → cli-a6ds2epe.js} +1 -1
- package/dist/{cli-esakngh8.js → cli-a89zdqz9.js} +1 -1
- package/dist/{cli-3ndmpxxv.js → cli-ay40dnj3.js} +2 -2
- package/dist/{cli-kf4jree9.js → cli-aypd09nf.js} +1 -1
- package/dist/{cli-6y51ryva.js → cli-b3yzyc57.js} +1 -1
- package/dist/{cli-ag2yrnt5.js → cli-c668vvcf.js} +2 -2
- package/dist/{cli-5sckpqcx.js → cli-d20qvpr2.js} +1 -1
- package/dist/{cli-a34pr3w1.js → cli-e1595bgx.js} +2 -2
- package/dist/{cli-m70bajzr.js → cli-f12mr20z.js} +1 -1
- package/dist/{cli-dqygeeh5.js → cli-f1e0ptjj.js} +10 -1
- package/dist/{cli-mvjz5an3.js → cli-f4dbh7a5.js} +7 -7
- package/dist/{cli-az25fn5f.js → cli-gsfk58nw.js} +2 -2
- package/dist/{cli-1tem6ns9.js → cli-gt4h8cxw.js} +3 -2
- package/dist/{cli-cz37jqss.js → cli-h5jeg46p.js} +2 -2
- package/dist/{cli-emhjcj2y.js → cli-he9gbdx0.js} +60 -10
- package/dist/{cli-3fwqy71j.js → cli-j0ppywdr.js} +38 -31
- package/dist/{cli-5rj83bam.js → cli-j3y0x2n4.js} +34 -33
- package/dist/{cli-3gfvmzht.js → cli-j9bpmykh.js} +73 -25
- package/dist/{cli-m5a9m0ak.js → cli-kv7sbc2c.js} +3 -3
- package/dist/{cli-g20jjwt4.js → cli-m30k9777.js} +12 -12
- package/dist/{cli-683y0b3a.js → cli-m5e802r7.js} +2 -2
- package/dist/{cli-nn6vx2mq.js → cli-ptfq202x.js} +5 -3
- package/dist/{cli-d2dpf95n.js → cli-qj442fws.js} +1 -1
- package/dist/{cli-03gg9pd1.js → cli-rd5m70gj.js} +4 -4
- package/dist/{cli-gnsb1zxa.js → cli-s09j9vp5.js} +1 -1
- package/dist/{cli-zqne1qcj.js → cli-s0mgx214.js} +2 -2
- package/dist/{cli-sqma6vw1.js → cli-sgqm2582.js} +5 -5
- package/dist/{cli-nq677csy.js → cli-sjctjr48.js} +1 -1
- package/dist/{cli-3aqd4jaa.js → cli-twctnkng.js} +3 -3
- package/dist/{cli-j6rktc91.js → cli-v6shg7na.js} +1 -1
- package/dist/{cli-7enjwya9.js → cli-vz78s5wm.js} +1 -1
- package/dist/{cli-art7c1zh.js → cli-wqr7cea4.js} +4 -4
- package/dist/{cli-hfeb68z4.js → cli-x37r7qwf.js} +11 -3
- package/dist/{cli-ffvsj2qw.js → cli-yd2nreqw.js} +2 -2
- package/dist/{cli-ca6banwz.js → cli-ymjpkxgn.js} +6 -6
- package/dist/{cli-mtnj4fwa.js → cli-yxbsmx56.js} +5 -5
- package/dist/cli.js +18 -16
- package/dist/{codex-adapter-d95q4qsn.js → codex-adapter-zk59x2zh.js} +5 -5
- package/dist/{codex-hook-brpnycn5.js → codex-hook-xyjhvxwd.js} +1 -1
- package/dist/{codex-session-runner-gdwd8h0c.js → codex-session-runner-kh6s4j3n.js} +5 -5
- package/dist/{commands-jqmgk54b.js → commands-68et1qv2.js} +2 -2
- package/dist/{controller-z3jq589f.js → controller-fyrbnzg4.js} +2 -2
- package/dist/{controllers-connect-r2za39ef.js → controllers-connect-nep9h2xv.js} +19 -19
- package/dist/{cursor-adapter-4mr4jgkw.js → cursor-adapter-yss3pg7t.js} +1 -1
- package/dist/{db-w36wwb5g.js → db-28gjyvxj.js} +2 -2
- package/dist/{db-ppv2tze7.js → db-8ftwvzba.js} +3 -3
- package/dist/{dispatcher-pv5h77vf.js → dispatcher-hayexmkr.js} +17 -17
- package/dist/{dsh-adapter-d4pdpb3r.js → dsh-adapter-46wawff9.js} +1 -1
- package/dist/{easter-egg-3d-4sp4dvmc.js → easter-egg-3d-8d1e0h4m.js} +1 -1
- package/dist/{execute-jev839y1.js → execute-cjrfxfnm.js} +1 -1
- package/dist/{external-channel-y6nkzhnn.js → external-channel-xbmxcg09.js} +2 -2
- package/dist/{grok-adapter-t08b9ntm.js → grok-adapter-ag4wks2a.js} +7 -7
- package/dist/handlers-tzmevqb5.js +41 -0
- package/dist/{heartbeat-vn31xwdk.js → heartbeat-psfvcgwp.js} +20 -20
- package/dist/{hook-6d0zbvvt.js → hook-8pz04697.js} +5 -5
- package/dist/{hook-cpjs8q51.js → hook-cyq4mryn.js} +6 -6
- package/dist/{http-9c6medda.js → http-haj1nd6k.js} +109 -114
- package/dist/{index-qhjf2htm.js → index-jwt2bj6f.js} +261 -96
- package/dist/{index-g6k9tctj.js → index-q52f47pe.js} +1 -1
- package/dist/{index-pkma1yf8.js → index-syvhnvtw.js} +1 -1
- package/dist/{index-yqgbfgaj.js → index-y1ajx04a.js} +20 -20
- package/dist/{index-k0yt1tvq.js → index-z5h22pes.js} +17 -17
- package/dist/{index-38t35h0n.js → index-zyq8ct52.js} +17 -17
- package/dist/{keepalive-em0117bp.js → keepalive-xdvsamvj.js} +3 -3
- package/dist/{lead-23e7z59t.js → lead-hqg57kdp.js} +35 -35
- package/dist/{maintenance-kbme3qy9.js → maintenance-4y2xzh3a.js} +1 -1
- package/dist/{oauth-refresh-sweep-xysj4yse.js → oauth-refresh-sweep-jd4r7zbs.js} +8 -3
- package/dist/{onboard-p6q0vz97.js → onboard-18447q9q.js} +5 -5
- package/dist/{opencode-adapter-esv3k9yn.js → opencode-adapter-26b2frdf.js} +5 -5
- package/dist/{otel-impl-54qhqh1z.js → otel-impl-f9tgr8yw.js} +1 -1
- package/dist/{pi-mono-adapter-mymbwhj0.js → pi-mono-adapter-qn6qbga1.js} +33 -12
- package/dist/{pricing-refresh-yb75wv33.js → pricing-refresh-dw2tgb53.js} +3 -3
- package/dist/{rbac-roles-1vp2mw4p.js → rbac-roles-aygm2760.js} +2 -2
- package/dist/{rbac-roles-dexhc9x8.js → rbac-roles-s4nwj2m3.js} +1 -1
- package/dist/{render-v2-tjay5hhf.js → render-v2-yfvdfr0x.js} +3 -3
- package/dist/{resume-8ayf37g5.js → resume-1bmvjbf3.js} +7 -7
- package/dist/{runtime-hqe5cqcd.js → runtime-f7tpzedy.js} +1 -1
- package/dist/{session-access-fdhp987s.js → session-access-r5garej6.js} +4 -4
- package/dist/{session-source-backfill-qgtc86z1.js → session-source-backfill-424wzv0r.js} +2 -2
- package/dist/{setup-4rrmqcq3.js → setup-8tygvxkq.js} +1 -1
- package/dist/{virtual-modules-52km00d6.js → virtual-modules-xsw323b3.js} +2 -2
- package/dist/{work-objects-p28fgd70.js → work-objects-ggnghpgf.js} +20 -20
- package/dist/{worker-e3w7vye1.js → worker-sqjd1nqf.js} +35 -35
- package/openapi.json +69 -1
- package/package.json +5 -3
- package/src/be/automation-preflight.ts +2 -0
- package/src/be/db/api-keys.ts +9 -0
- package/src/be/db/workflow-runs.ts +36 -5
- package/src/be/db.ts +80 -22
- package/src/be/memory/task-learning.ts +155 -0
- package/src/be/migrations/245_script_executor.sql +3 -0
- package/src/be/oauth-refresh-sweep.ts +2 -1
- package/src/be/scripts/boot-reembed.ts +2 -0
- package/src/be/scripts/db.ts +14 -4
- package/src/be/scripts/embeddings.ts +2 -0
- package/src/be/scripts/run-saved.ts +1 -0
- package/src/be/scripts/typecheck.ts +9 -3
- package/src/be/seed-skills/index.ts +7 -0
- package/src/commands/onboard/compose-generator.ts +5 -3
- package/src/commands/provider-credentials.ts +2 -1
- package/src/heartbeat/heartbeat.ts +14 -1
- package/src/heartbeat/script-runner-probe.ts +47 -0
- package/src/http/api-keys.ts +24 -9
- package/src/http/idempotency-key.ts +1 -1
- package/src/http/index.ts +3 -3
- package/src/http/pages-public.ts +38 -5
- package/src/http/scripts.ts +27 -0
- package/src/http/workflows.ts +10 -0
- package/src/http/x.ts +5 -0
- package/src/prompts/base-prompt.ts +21 -1
- package/src/prompts/session-templates.ts +17 -3
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +41 -11
- package/src/scheduler/scheduler.ts +2 -1
- package/src/scripts-runtime/eval-harness.ts +8 -1
- package/src/scripts-runtime/executors/native.ts +22 -16
- package/src/scripts-runtime/executors/quickjs-prelude.ts +103 -1
- package/src/scripts-runtime/executors/quickjs-runner.ts +2 -1
- package/src/scripts-runtime/executors/quickjs.ts +7 -0
- package/src/scripts-runtime/executors/registry.ts +32 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/host-validated-args.ts +133 -0
- package/src/scripts-runtime/loader.ts +30 -3
- package/src/scripts-runtime/swarm-sdk.ts +9 -2
- package/src/scripts-runtime/types/stdlib.d.ts +9 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +9 -1
- package/src/tests/api-key-status-by-suffix.test.ts +242 -0
- package/src/tests/base-prompt.test.ts +70 -2
- package/src/tests/community-linear-delivery-loop.test.ts +2099 -0
- package/src/tests/credential-check.test.ts +4 -2
- package/src/tests/docs-index.test.ts +135 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/fetch-resolved-env.test.ts +16 -2
- package/src/tests/heartbeat-checklist.test.ts +89 -0
- package/src/tests/heartbeat-script-runner-probe.test.ts +83 -0
- package/src/tests/oauth-refresh-sweep.test.ts +34 -1
- package/src/tests/onboard-compose.test.ts +20 -0
- package/src/tests/opencode-adapter.test.ts +2 -1
- package/src/tests/pages-frame-ancestors.test.ts +162 -0
- package/src/tests/pi-codemode.test.ts +2 -1
- package/src/tests/pi-mono-adapter.test.ts +54 -0
- package/src/tests/pi-native-mcp.test.ts +13 -1
- package/src/tests/pi-tool-exposure.test.ts +8 -8
- package/src/tests/probe-tool-schema-tokens.test.ts +144 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/request-composition-derive.test.ts +329 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/script-executor-conformance.test.ts +36 -1
- package/src/tests/script-executor-quickjs-pool.test.ts +50 -0
- package/src/tests/script-executor-quickjs.test.ts +140 -0
- package/src/tests/script-host-validated-args-native.test.ts +196 -0
- package/src/tests/script-per-script-executor.test.ts +415 -0
- package/src/tests/scripts-external-api.test.ts +45 -0
- package/src/tests/sdk-allowlist.test.ts +3 -2
- package/src/tests/self-improvement.test.ts +4 -4
- package/src/tests/store-progress-learning.test.ts +288 -0
- package/src/tests/ui-logs-parser.test.ts +180 -0
- package/src/tests/workflow-engine-v2.test.ts +116 -1
- package/src/tests/workflow-mcp-trigger-schema.test.ts +209 -0
- package/src/tests/workflow-run-finished-event.test.ts +263 -0
- package/src/tests/workflow-swarm-script.test.ts +34 -0
- package/src/tools/script-common.ts +1 -0
- package/src/tools/script-run.ts +7 -0
- package/src/tools/script-upsert.ts +7 -0
- package/src/tools/store-progress.ts +67 -1
- package/src/tools/workflows/trigger-workflow.ts +104 -10
- package/src/types.ts +12 -1
- package/src/utils/credentials.ts +5 -2
- package/src/workflows/engine.ts +18 -16
- package/src/workflows/executors/swarm-script.ts +5 -0
- package/src/workflows/foreach-driver.ts +5 -11
- package/src/workflows/node-timeout.ts +30 -0
- package/templates/community/linear-delivery-loop/PLAYBOOK.md +323 -0
- package/templates/community/linear-delivery-loop/README.md +38 -0
- package/templates/community/linear-delivery-loop/config.example.json +31 -0
- package/templates/community/linear-delivery-loop/lead-prompt.md +34 -0
- package/templates/community/linear-delivery-loop/schedule.json +10 -0
- package/templates/community/linear-delivery-loop/scripts/linear-delivery-setup.ts +148 -0
- package/templates/community/linear-delivery-loop/scripts/linear-delivery-tick.ts +1781 -0
- package/templates/skills/swarm-docs/config.json +14 -0
- package/templates/skills/swarm-docs/content.md +144 -0
- package/dist/handlers-eqsnmv95.js +0 -41
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { ZodType } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createAgent,
|
|
7
|
+
createTaskExtended,
|
|
8
|
+
getDb,
|
|
9
|
+
getTaskById,
|
|
10
|
+
initDb,
|
|
11
|
+
startTask,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { registerStoreProgressTool } from "../tools/store-progress";
|
|
14
|
+
|
|
15
|
+
interface ToolResult {
|
|
16
|
+
isError?: boolean;
|
|
17
|
+
structuredContent?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const agentId = "dddd0000-0000-4000-8000-000000000b01";
|
|
21
|
+
|
|
22
|
+
const server = new McpServer({ name: "store-progress-learning", version: "1.0.0" });
|
|
23
|
+
registerStoreProgressTool(server);
|
|
24
|
+
const tool = (
|
|
25
|
+
server as unknown as {
|
|
26
|
+
_registeredTools: Record<
|
|
27
|
+
string,
|
|
28
|
+
{
|
|
29
|
+
inputSchema: ZodType;
|
|
30
|
+
handler: (args: unknown, meta: unknown) => Promise<ToolResult>;
|
|
31
|
+
}
|
|
32
|
+
>;
|
|
33
|
+
}
|
|
34
|
+
)._registeredTools["store-progress"]!;
|
|
35
|
+
|
|
36
|
+
async function call(args: Record<string, unknown>) {
|
|
37
|
+
return tool.handler(tool.inputSchema.parse(args), {
|
|
38
|
+
sessionId: "store-progress-learning-session",
|
|
39
|
+
requestInfo: { headers: { "x-agent-id": agentId } },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function task(vcsRepo?: string) {
|
|
44
|
+
const row = await createTaskExtended("learning capture", {
|
|
45
|
+
agentId,
|
|
46
|
+
source: "system",
|
|
47
|
+
followUpConfig: { disabled: true },
|
|
48
|
+
...(vcsRepo ? { vcsRepo } : {}),
|
|
49
|
+
});
|
|
50
|
+
await startTask(row.id);
|
|
51
|
+
return row;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MemoryRow {
|
|
55
|
+
id: string;
|
|
56
|
+
agentId: string | null;
|
|
57
|
+
key: string;
|
|
58
|
+
name: string;
|
|
59
|
+
scope: string;
|
|
60
|
+
source: string;
|
|
61
|
+
sourceTaskId: string;
|
|
62
|
+
tags: string;
|
|
63
|
+
content: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function learningRows(taskId: string): MemoryRow[] {
|
|
67
|
+
return getDb()
|
|
68
|
+
.query<MemoryRow, [string]>(
|
|
69
|
+
"SELECT id, agentId, key, name, scope, source, sourceTaskId, tags, content FROM agent_memory WHERE sourceTaskId = ? AND key LIKE '/learnings/%' ORDER BY createdAt",
|
|
70
|
+
)
|
|
71
|
+
.all(taskId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function learningData(result: ToolResult) {
|
|
75
|
+
return result.structuredContent?.learning as
|
|
76
|
+
| { status: string; key?: string; error?: string }
|
|
77
|
+
| undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function message(result: ToolResult) {
|
|
81
|
+
return String(result.structuredContent?.message);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const originalEmbeddingKey = process.env.EMBEDDING_API_KEY;
|
|
85
|
+
const originalOpenAiKey = process.env.OPENAI_API_KEY;
|
|
86
|
+
beforeAll(async () => {
|
|
87
|
+
process.env.EMBEDDING_API_KEY = "";
|
|
88
|
+
process.env.OPENAI_API_KEY = "";
|
|
89
|
+
initDb(":memory:");
|
|
90
|
+
await createAgent({ id: agentId, name: "Learner", isLead: false, status: "idle" });
|
|
91
|
+
});
|
|
92
|
+
afterAll(() => {
|
|
93
|
+
closeDb();
|
|
94
|
+
if (originalEmbeddingKey === undefined) delete process.env.EMBEDDING_API_KEY;
|
|
95
|
+
else process.env.EMBEDDING_API_KEY = originalEmbeddingKey;
|
|
96
|
+
if (originalOpenAiKey === undefined) delete process.env.OPENAI_API_KEY;
|
|
97
|
+
else process.env.OPENAI_API_KEY = originalOpenAiKey;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("store-progress learning", () => {
|
|
101
|
+
test("stores a swarm manual memory under the explicit workstream", async () => {
|
|
102
|
+
const t = await task("desplega-ai/agent-swarm");
|
|
103
|
+
const result = await call({
|
|
104
|
+
taskId: t.id,
|
|
105
|
+
progress: "found the cause",
|
|
106
|
+
learning: {
|
|
107
|
+
text: "SQLite BUSY needs BEGIN IMMEDIATE\nA deferred BEGIN that reads first fails with BUSY_SNAPSHOT.",
|
|
108
|
+
workstream: "DB Runtime",
|
|
109
|
+
tags: ["SQLite", "gotcha", "learning"],
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(result.isError).toBeFalsy();
|
|
114
|
+
expect(learningData(result)?.status).toBe("stored");
|
|
115
|
+
expect(message(result)).toContain(
|
|
116
|
+
"Learning stored at /learnings/db-runtime/sqlite-busy-needs-begin-immediate.",
|
|
117
|
+
);
|
|
118
|
+
const [row, ...rest] = learningRows(t.id);
|
|
119
|
+
expect(rest).toHaveLength(0);
|
|
120
|
+
expect(row).toMatchObject({
|
|
121
|
+
agentId,
|
|
122
|
+
key: "/learnings/db-runtime/sqlite-busy-needs-begin-immediate",
|
|
123
|
+
name: "SQLite BUSY needs BEGIN IMMEDIATE",
|
|
124
|
+
scope: "swarm",
|
|
125
|
+
source: "manual",
|
|
126
|
+
sourceTaskId: t.id,
|
|
127
|
+
});
|
|
128
|
+
expect(JSON.parse(row!.tags)).toEqual(["learning", "sqlite", "gotcha"]);
|
|
129
|
+
expect((await getTaskById(t.id))?.progress).toBe("found the cause");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("string form falls back to the repo name, then to general", async () => {
|
|
133
|
+
const withRepo = await task("desplega-ai/agent-swarm");
|
|
134
|
+
await call({ taskId: withRepo.id, learning: "Run lint before tsc" });
|
|
135
|
+
expect(learningRows(withRepo.id)[0]?.key).toBe("/learnings/agent-swarm/run-lint-before-tsc");
|
|
136
|
+
|
|
137
|
+
const withoutRepo = await task();
|
|
138
|
+
const result = await call({ taskId: withoutRepo.id, learning: { text: "Prefer scripts" } });
|
|
139
|
+
expect(learningData(result)).toMatchObject({ status: "stored", workstream: "general" });
|
|
140
|
+
expect(learningRows(withoutRepo.id)[0]?.key).toBe("/learnings/general/prefer-scripts");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a taken key gets a short suffix instead of failing", async () => {
|
|
144
|
+
const first = await task();
|
|
145
|
+
const second = await task();
|
|
146
|
+
await call({ taskId: first.id, learning: "Same title\nfirst body" });
|
|
147
|
+
const result = await call({ taskId: second.id, learning: "Same title\nsecond body" });
|
|
148
|
+
|
|
149
|
+
expect(learningData(result)?.status).toBe("stored");
|
|
150
|
+
expect(learningRows(first.id)[0]?.key).toBe("/learnings/general/same-title");
|
|
151
|
+
expect(learningRows(second.id)[0]?.key).toMatch(
|
|
152
|
+
/^\/learnings\/general\/same-title-[0-9a-f]{6}$/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("an exact replay on the same task is skipped", async () => {
|
|
157
|
+
const t = await task();
|
|
158
|
+
await call({ taskId: t.id, learning: "Replay me" });
|
|
159
|
+
const replay = await call({ taskId: t.id, progress: "again", learning: "Replay me" });
|
|
160
|
+
|
|
161
|
+
expect(learningData(replay)).toMatchObject({
|
|
162
|
+
status: "replayed",
|
|
163
|
+
key: "/learnings/general/replay-me",
|
|
164
|
+
});
|
|
165
|
+
expect(learningRows(t.id)).toHaveLength(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("overlapping calls with the same learning store it once", async () => {
|
|
169
|
+
const t = await task();
|
|
170
|
+
const learning = "Concurrent replay\nKeep exactly one copy of this lesson.";
|
|
171
|
+
const results = await Promise.all([
|
|
172
|
+
call({ taskId: t.id, learning }),
|
|
173
|
+
call({ taskId: t.id, learning }),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(learningRows(t.id)).toHaveLength(1);
|
|
177
|
+
expect(results.map((result) => learningData(result)?.status).sort()).toEqual([
|
|
178
|
+
"replayed",
|
|
179
|
+
"stored",
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Synthetic token, built at runtime so no literal credential sits in the repo.
|
|
184
|
+
const token = `ghp_${"abcd1234".repeat(5)}`;
|
|
185
|
+
const kebabToken = token.replace("_", "-");
|
|
186
|
+
|
|
187
|
+
test("a secret in the title, workstream or tags never reaches the key or tags", async () => {
|
|
188
|
+
const t = await task();
|
|
189
|
+
const result = await call({
|
|
190
|
+
taskId: t.id,
|
|
191
|
+
learning: {
|
|
192
|
+
text: `${token}\nKeep credentials out of memory.`,
|
|
193
|
+
workstream: token,
|
|
194
|
+
tags: [token],
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(learningData(result)?.status).toBe("stored");
|
|
199
|
+
const [row] = learningRows(t.id);
|
|
200
|
+
for (const field of [row!.key, row!.name, row!.tags, row!.content, learningData(result)?.key]) {
|
|
201
|
+
expect(field).not.toContain(token);
|
|
202
|
+
expect(field).not.toContain(kebabToken);
|
|
203
|
+
}
|
|
204
|
+
expect(row!.content).toContain("[REDACTED:");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a replay of a secret-bearing learning is still detected", async () => {
|
|
208
|
+
const t = await task();
|
|
209
|
+
const learning = `Redact diagnostic tokens\nExample token: ${token}`;
|
|
210
|
+
await call({ taskId: t.id, learning });
|
|
211
|
+
const replay = await call({ taskId: t.id, learning });
|
|
212
|
+
|
|
213
|
+
expect(learningData(replay)?.status).toBe("replayed");
|
|
214
|
+
expect(learningRows(t.id)).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a failed learning write leaves the progress write intact", async () => {
|
|
218
|
+
const t = await task();
|
|
219
|
+
getDb().run(
|
|
220
|
+
"CREATE TEMP TRIGGER fail_learning BEFORE INSERT ON agent_memory WHEN NEW.key LIKE '/learnings/%' BEGIN SELECT RAISE(ABORT, 'learning insert refused'); END",
|
|
221
|
+
);
|
|
222
|
+
try {
|
|
223
|
+
const result = await call({
|
|
224
|
+
taskId: t.id,
|
|
225
|
+
progress: "progress survives",
|
|
226
|
+
status: "completed",
|
|
227
|
+
output: "done",
|
|
228
|
+
learning: "This write fails",
|
|
229
|
+
});
|
|
230
|
+
expect(result.isError).toBeFalsy();
|
|
231
|
+
expect(learningData(result)).toMatchObject({ status: "failed" });
|
|
232
|
+
expect(learningData(result)?.error).toContain("learning insert refused");
|
|
233
|
+
expect(message(result)).toContain("Learning not stored:");
|
|
234
|
+
} finally {
|
|
235
|
+
getDb().run("DROP TRIGGER fail_learning");
|
|
236
|
+
}
|
|
237
|
+
const stored = await getTaskById(t.id);
|
|
238
|
+
expect(stored?.progress).toBe("progress survives");
|
|
239
|
+
expect(stored?.status).toBe("completed");
|
|
240
|
+
expect(learningRows(t.id)).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("invalid learnings are reported without blocking progress", async () => {
|
|
244
|
+
const t = await task();
|
|
245
|
+
const tooManyTags = await call({
|
|
246
|
+
taskId: t.id,
|
|
247
|
+
progress: "still stored",
|
|
248
|
+
learning: { text: "Tagged", tags: ["a", "b", "c", "d", "e", "f"] },
|
|
249
|
+
});
|
|
250
|
+
expect(learningData(tooManyTags)).toMatchObject({
|
|
251
|
+
status: "failed",
|
|
252
|
+
error: "learning takes at most 5 tags",
|
|
253
|
+
});
|
|
254
|
+
const tooLong = await call({ taskId: t.id, learning: "x".repeat(2001) });
|
|
255
|
+
expect(learningData(tooLong)?.status).toBe("failed");
|
|
256
|
+
expect((await getTaskById(t.id))?.progress).toBe("still stored");
|
|
257
|
+
expect(learningRows(t.id)).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a learning on the completing call is stored", async () => {
|
|
261
|
+
const t = await task();
|
|
262
|
+
const result = await call({
|
|
263
|
+
taskId: t.id,
|
|
264
|
+
status: "completed",
|
|
265
|
+
output: "done",
|
|
266
|
+
learning: "Ship on the completing call",
|
|
267
|
+
});
|
|
268
|
+
expect(learningData(result)?.status).toBe("stored");
|
|
269
|
+
expect((await getTaskById(t.id))?.status).toBe("completed");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("a call on a finished task stores nothing", async () => {
|
|
273
|
+
const t = await task();
|
|
274
|
+
await call({ taskId: t.id, status: "completed", output: "done" });
|
|
275
|
+
const late = await call({
|
|
276
|
+
taskId: t.id,
|
|
277
|
+
status: "completed",
|
|
278
|
+
output: "done",
|
|
279
|
+
learning: "Too late",
|
|
280
|
+
});
|
|
281
|
+
expect(late.structuredContent).toMatchObject({ wasNoOp: true });
|
|
282
|
+
expect(learningData(late)?.status).toBe("failed");
|
|
283
|
+
|
|
284
|
+
const progressOnly = await call({ taskId: t.id, learning: "Still too late" });
|
|
285
|
+
expect(learningData(progressOnly)?.status).toBe("failed");
|
|
286
|
+
expect(learningRows(t.id)).toHaveLength(0);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { normalizeSessionLogs as normalizeEvalLogs } from "../../apps/evals/ui/src/logs-parser";
|
|
3
3
|
import {
|
|
4
|
+
cacheHitRatio,
|
|
4
5
|
extractSubagentRuns,
|
|
5
6
|
normalizeSessionLogs,
|
|
6
7
|
parseSessionLogs,
|
|
7
8
|
type SessionLogRecord,
|
|
9
|
+
sumItemCallUsage,
|
|
8
10
|
unwrapResult,
|
|
9
11
|
} from "../../apps/ui/src/logs-parser";
|
|
10
12
|
|
|
@@ -1696,3 +1698,181 @@ describe.each([
|
|
|
1696
1698
|
expect(item?.meta).toMatchObject({ type: "codex_error", isError: true });
|
|
1697
1699
|
});
|
|
1698
1700
|
});
|
|
1701
|
+
|
|
1702
|
+
describe("per-call usage", () => {
|
|
1703
|
+
const claudeUsage = {
|
|
1704
|
+
input_tokens: 6,
|
|
1705
|
+
cache_read_input_tokens: 90_000,
|
|
1706
|
+
cache_creation_input_tokens: 684,
|
|
1707
|
+
output_tokens: 120,
|
|
1708
|
+
};
|
|
1709
|
+
function claudeLine(
|
|
1710
|
+
id: string,
|
|
1711
|
+
line: number,
|
|
1712
|
+
messageId: string,
|
|
1713
|
+
block: unknown,
|
|
1714
|
+
extra: Record<string, unknown> = {},
|
|
1715
|
+
) {
|
|
1716
|
+
return log(id, "claude", line, {
|
|
1717
|
+
type: "assistant",
|
|
1718
|
+
message: { id: messageId, role: "assistant", content: [block], usage: claudeUsage },
|
|
1719
|
+
...extra,
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
const usages = (logs: SessionLogRecord[]) =>
|
|
1723
|
+
normalizeSessionLogs(logs)
|
|
1724
|
+
.items.filter((item) => item.kind === "call_usage")
|
|
1725
|
+
.map((item) => item.usage);
|
|
1726
|
+
|
|
1727
|
+
test("claude: one call_usage per message.id, not per content-block line", () => {
|
|
1728
|
+
const result = usages([
|
|
1729
|
+
claudeLine("a1", 1, "msg_1", { type: "thinking", thinking: "" }),
|
|
1730
|
+
claudeLine("a2", 2, "msg_1", { type: "text", text: "Looking." }),
|
|
1731
|
+
claudeLine("a3", 3, "msg_1", { type: "tool_use", id: "t1", name: "Read", input: {} }),
|
|
1732
|
+
claudeLine(
|
|
1733
|
+
"s1",
|
|
1734
|
+
4,
|
|
1735
|
+
"msg_2",
|
|
1736
|
+
{ type: "text", text: "sub" },
|
|
1737
|
+
{
|
|
1738
|
+
parent_tool_use_id: "toolu_agent",
|
|
1739
|
+
},
|
|
1740
|
+
),
|
|
1741
|
+
]);
|
|
1742
|
+
expect(result).toHaveLength(2);
|
|
1743
|
+
expect(result[0]).toMatchObject({
|
|
1744
|
+
callId: "msg_1",
|
|
1745
|
+
promptTokens: 90_690,
|
|
1746
|
+
cacheReadTokens: 90_000,
|
|
1747
|
+
cacheWriteTokens: 684,
|
|
1748
|
+
uncachedInputTokens: 6,
|
|
1749
|
+
outputTokens: 120,
|
|
1750
|
+
sidechain: false,
|
|
1751
|
+
harness: "claude",
|
|
1752
|
+
reportedAt: "start",
|
|
1753
|
+
});
|
|
1754
|
+
expect(result[1]).toMatchObject({ callId: "msg_2", sidechain: true });
|
|
1755
|
+
expect(cacheHitRatio(result[0]!)).toBeCloseTo(90_000 / 90_690);
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test("claude: an assistant line without usage emits no call_usage", () => {
|
|
1759
|
+
const result = usages([
|
|
1760
|
+
log("a1", "claude", 1, {
|
|
1761
|
+
type: "assistant",
|
|
1762
|
+
message: { id: "msg_1", role: "assistant", content: [{ type: "text", text: "hi" }] },
|
|
1763
|
+
}),
|
|
1764
|
+
]);
|
|
1765
|
+
expect(result).toEqual([]);
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
test("the call_usage block shares the call's message, ahead of its content", () => {
|
|
1769
|
+
const messages = parseSessionLogs([
|
|
1770
|
+
claudeLine("a1", 1, "msg_1", { type: "text", text: "Looking." }),
|
|
1771
|
+
]);
|
|
1772
|
+
expect(messages).toHaveLength(1);
|
|
1773
|
+
expect(messages[0]?.role).toBe("assistant");
|
|
1774
|
+
expect(messages[0]?.content.map((b) => b.type)).toEqual(["provider_meta", "text"]);
|
|
1775
|
+
expect(messages[0]?.content[0]).toMatchObject({
|
|
1776
|
+
kind: "helper",
|
|
1777
|
+
data: { helperType: "call_usage" },
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
test("codex: context.updated input_tokens already includes cached tokens", () => {
|
|
1782
|
+
const result = usages([
|
|
1783
|
+
log("c1", "codex", 1, {
|
|
1784
|
+
type: "context.updated",
|
|
1785
|
+
usage: {
|
|
1786
|
+
input_tokens: 49_300,
|
|
1787
|
+
cached_input_tokens: 12_000,
|
|
1788
|
+
cache_write_input_tokens: 0,
|
|
1789
|
+
output_tokens: 300,
|
|
1790
|
+
reasoning_output_tokens: 40,
|
|
1791
|
+
},
|
|
1792
|
+
contextWindow: 272_000,
|
|
1793
|
+
}),
|
|
1794
|
+
]);
|
|
1795
|
+
expect(result).toEqual([
|
|
1796
|
+
{
|
|
1797
|
+
callId: "c1",
|
|
1798
|
+
promptTokens: 49_300,
|
|
1799
|
+
cacheReadTokens: 12_000,
|
|
1800
|
+
cacheWriteTokens: 0,
|
|
1801
|
+
uncachedInputTokens: 37_300,
|
|
1802
|
+
outputTokens: 300,
|
|
1803
|
+
reasoningTokens: 40,
|
|
1804
|
+
sidechain: false,
|
|
1805
|
+
harness: "codex",
|
|
1806
|
+
reportedAt: "end",
|
|
1807
|
+
},
|
|
1808
|
+
]);
|
|
1809
|
+
expect(cacheHitRatio(result[0]!)).toBeCloseTo(12_000 / 49_300);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
test("opencode: session.step.ended input excludes cache", () => {
|
|
1813
|
+
const result = usages([
|
|
1814
|
+
log("o1", "opencode", 1, {
|
|
1815
|
+
type: "session.step.ended",
|
|
1816
|
+
data: {
|
|
1817
|
+
sessionID: "ses_1",
|
|
1818
|
+
tokens: { input: 200, output: 50, reasoning: 5, cache: { read: 9_000, write: 800 } },
|
|
1819
|
+
},
|
|
1820
|
+
}),
|
|
1821
|
+
]);
|
|
1822
|
+
expect(result).toHaveLength(1);
|
|
1823
|
+
expect(result[0]).toMatchObject({
|
|
1824
|
+
promptTokens: 10_000,
|
|
1825
|
+
cacheReadTokens: 9_000,
|
|
1826
|
+
cacheWriteTokens: 800,
|
|
1827
|
+
uncachedInputTokens: 200,
|
|
1828
|
+
harness: "opencode",
|
|
1829
|
+
reportedAt: "end",
|
|
1830
|
+
});
|
|
1831
|
+
expect(cacheHitRatio(result[0]!)).toBeCloseTo(0.9);
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
test("pi: the adapter's call_usage line", () => {
|
|
1835
|
+
const result = usages([
|
|
1836
|
+
log("p1", "pi", 1, {
|
|
1837
|
+
type: "call_usage",
|
|
1838
|
+
harness: "pi",
|
|
1839
|
+
usage: { input: 100, cacheRead: 300, cacheWrite: 100, output: 20, reasoning: 0 },
|
|
1840
|
+
}),
|
|
1841
|
+
]);
|
|
1842
|
+
expect(result).toHaveLength(1);
|
|
1843
|
+
expect(result[0]).toMatchObject({
|
|
1844
|
+
promptTokens: 500,
|
|
1845
|
+
cacheReadTokens: 300,
|
|
1846
|
+
harness: "pi",
|
|
1847
|
+
reportedAt: "start",
|
|
1848
|
+
});
|
|
1849
|
+
expect(cacheHitRatio(result[0]!)).toBeCloseTo(0.6);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
test("session totals: Σ read / Σ prompt, and no-cache sessions say so", () => {
|
|
1853
|
+
const totals = sumItemCallUsage(
|
|
1854
|
+
normalizeSessionLogs([
|
|
1855
|
+
claudeLine("a1", 1, "msg_1", { type: "text", text: "one" }),
|
|
1856
|
+
claudeLine("a2", 2, "msg_2", { type: "text", text: "two" }),
|
|
1857
|
+
]).items,
|
|
1858
|
+
);
|
|
1859
|
+
expect(totals).toMatchObject({
|
|
1860
|
+
calls: 2,
|
|
1861
|
+
promptTokens: 181_380,
|
|
1862
|
+
cacheReadTokens: 180_000,
|
|
1863
|
+
cacheReported: true,
|
|
1864
|
+
});
|
|
1865
|
+
expect(totals?.ratio).toBeCloseTo(180_000 / 181_380);
|
|
1866
|
+
|
|
1867
|
+
const uncached = sumItemCallUsage(
|
|
1868
|
+
normalizeSessionLogs([
|
|
1869
|
+
log("o1", "opencode", 1, {
|
|
1870
|
+
type: "session.step.ended",
|
|
1871
|
+
data: { tokens: { input: 500, output: 5, reasoning: 0, cache: { read: 0, write: 0 } } },
|
|
1872
|
+
}),
|
|
1873
|
+
]).items,
|
|
1874
|
+
);
|
|
1875
|
+
expect(uncached).toMatchObject({ calls: 1, cacheReported: false, ratio: 0 });
|
|
1876
|
+
expect(sumItemCallUsage([])).toBeNull();
|
|
1877
|
+
});
|
|
1878
|
+
});
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
getWorkflowRunStepsByRunId,
|
|
22
22
|
initDb,
|
|
23
23
|
} from "../be/db";
|
|
24
|
-
import type { Workflow, WorkflowDefinition } from "../types";
|
|
24
|
+
import type { ExecutorMeta, Workflow, WorkflowDefinition } from "../types";
|
|
25
25
|
import { shouldSkipCooldown } from "../workflows/cooldown";
|
|
26
26
|
import {
|
|
27
27
|
findReadyNodes,
|
|
@@ -143,6 +143,45 @@ class SlowExecutor extends BaseExecutor<typeof SlowExecutor.schema, typeof SlowE
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** What the last `signal-probe` step saw: its abort signal and whether it got to its side effect. */
|
|
147
|
+
const signalProbe: { signal?: AbortSignal; sideEffect: boolean } = { sideEffect: false };
|
|
148
|
+
|
|
149
|
+
class SignalProbeExecutor extends BaseExecutor<
|
|
150
|
+
typeof SignalProbeExecutor.schema,
|
|
151
|
+
typeof SignalProbeExecutor.outSchema
|
|
152
|
+
> {
|
|
153
|
+
static readonly schema = z.object({ delayMs: z.number() });
|
|
154
|
+
static readonly outSchema = z.object({ finished: z.boolean() });
|
|
155
|
+
|
|
156
|
+
readonly type = "signal-probe";
|
|
157
|
+
readonly mode = "instant" as const;
|
|
158
|
+
readonly configSchema = SignalProbeExecutor.schema;
|
|
159
|
+
readonly outputSchema = SignalProbeExecutor.outSchema;
|
|
160
|
+
|
|
161
|
+
protected async execute(
|
|
162
|
+
config: z.infer<typeof SignalProbeExecutor.schema>,
|
|
163
|
+
_context: Readonly<Record<string, unknown>>,
|
|
164
|
+
meta: ExecutorMeta,
|
|
165
|
+
): Promise<ExecutorResult<z.infer<typeof SignalProbeExecutor.outSchema>>> {
|
|
166
|
+
const signal = meta.signal;
|
|
167
|
+
signalProbe.signal = signal;
|
|
168
|
+
await new Promise<void>((resolve) => {
|
|
169
|
+
const timer = setTimeout(resolve, config.delayMs);
|
|
170
|
+
signal?.addEventListener(
|
|
171
|
+
"abort",
|
|
172
|
+
() => {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
resolve();
|
|
175
|
+
},
|
|
176
|
+
{ once: true },
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
// A one-shot action, like the review trigger a claim script fires.
|
|
180
|
+
if (!signal?.aborted) signalProbe.sideEffect = true;
|
|
181
|
+
return { status: "success", output: { finished: true } };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
146
185
|
class FailingExecutor extends BaseExecutor<
|
|
147
186
|
typeof FailingExecutor.schema,
|
|
148
187
|
typeof FailingExecutor.outSchema
|
|
@@ -216,6 +255,7 @@ function createTestRegistry(): ExecutorRegistry {
|
|
|
216
255
|
registry.register(new SlowExecutor(mockDeps));
|
|
217
256
|
registry.register(new FailingExecutor(mockDeps));
|
|
218
257
|
registry.register(new ValidatePassExecutor(mockDeps));
|
|
258
|
+
registry.register(new SignalProbeExecutor(mockDeps));
|
|
219
259
|
return registry;
|
|
220
260
|
}
|
|
221
261
|
|
|
@@ -561,6 +601,41 @@ describe("Workflow Engine v2 (Phase 3)", () => {
|
|
|
561
601
|
expect(run!.error).toContain("timed out");
|
|
562
602
|
}, 5_000); // Allow test up to 5s
|
|
563
603
|
|
|
604
|
+
test("a timed-out step aborts its executor, so the side effect never happens", async () => {
|
|
605
|
+
signalProbe.signal = undefined;
|
|
606
|
+
signalProbe.sideEffect = false;
|
|
607
|
+
const registry = createTestRegistry();
|
|
608
|
+
const workflow = await makeWorkflow({
|
|
609
|
+
nodes: [{ id: "probe", type: "signal-probe", config: { delayMs: 600, timeoutMs: 100 } }],
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
613
|
+
|
|
614
|
+
const run = await getWorkflowRun(runId);
|
|
615
|
+
expect(run!.status).toBe("failed");
|
|
616
|
+
expect(run!.error).toContain("Step timed out after 100ms");
|
|
617
|
+
expect(signalProbe.signal?.aborted).toBe(true);
|
|
618
|
+
// Past the executor's own delay: without the abort it would have fired.
|
|
619
|
+
await Bun.sleep(700);
|
|
620
|
+
expect(signalProbe.sideEffect).toBe(false);
|
|
621
|
+
}, 5_000);
|
|
622
|
+
|
|
623
|
+
test("a step that finishes in budget is never aborted afterwards", async () => {
|
|
624
|
+
signalProbe.signal = undefined;
|
|
625
|
+
signalProbe.sideEffect = false;
|
|
626
|
+
const registry = createTestRegistry();
|
|
627
|
+
const workflow = await makeWorkflow({
|
|
628
|
+
nodes: [{ id: "probe", type: "signal-probe", config: { delayMs: 10, timeoutMs: 150 } }],
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
632
|
+
|
|
633
|
+
expect((await getWorkflowRun(runId))!.status).toBe("completed");
|
|
634
|
+
expect(signalProbe.sideEffect).toBe(true);
|
|
635
|
+
await Bun.sleep(250);
|
|
636
|
+
expect(signalProbe.signal?.aborted).toBe(false);
|
|
637
|
+
}, 5_000);
|
|
638
|
+
|
|
564
639
|
// The inline runtime's ulimit preamble fails on macOS (no usable
|
|
565
640
|
// RLIMIT_AS), so the script dies before the watchdog behavior under test.
|
|
566
641
|
// Linux CI is the enforcing environment; the skip only unblocks local
|
|
@@ -598,6 +673,46 @@ describe("Workflow Engine v2 (Phase 3)", () => {
|
|
|
598
673
|
|
|
599
674
|
// ─── Validation ───────────────────────────────────────────
|
|
600
675
|
|
|
676
|
+
describe("Detached start", () => {
|
|
677
|
+
test("detach returns once the run is committed and walks its instant steps in the background", async () => {
|
|
678
|
+
const registry = createTestRegistry();
|
|
679
|
+
const workflow = await makeWorkflow({
|
|
680
|
+
nodes: [
|
|
681
|
+
{ id: "slow", type: "slow", config: { delayMs: 400 }, next: "after" },
|
|
682
|
+
{ id: "after", type: "echo", config: { message: "done" } },
|
|
683
|
+
],
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const started = performance.now();
|
|
687
|
+
const runId = await startWorkflowExecution(workflow, {}, registry, { detach: true });
|
|
688
|
+
const returnedAfterMs = performance.now() - started;
|
|
689
|
+
|
|
690
|
+
expect(returnedAfterMs).toBeLessThan(300);
|
|
691
|
+
expect((await getWorkflowRun(runId))!.status).toBe("running");
|
|
692
|
+
|
|
693
|
+
const deadline = Date.now() + 3_000;
|
|
694
|
+
while ((await getWorkflowRun(runId))!.status === "running" && Date.now() < deadline) {
|
|
695
|
+
await Bun.sleep(25);
|
|
696
|
+
}
|
|
697
|
+
expect((await getWorkflowRun(runId))!.status).toBe("completed");
|
|
698
|
+
const steps = await getWorkflowRunStepsByRunId(runId);
|
|
699
|
+
expect(steps.map((step) => step.nodeId).sort()).toEqual(["after", "slow"]);
|
|
700
|
+
}, 5_000);
|
|
701
|
+
|
|
702
|
+
test("without detach the start resolves only after the instant steps ran", async () => {
|
|
703
|
+
const registry = createTestRegistry();
|
|
704
|
+
const workflow = await makeWorkflow({
|
|
705
|
+
nodes: [{ id: "slow", type: "slow", config: { delayMs: 300 } }],
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const started = performance.now();
|
|
709
|
+
const runId = await startWorkflowExecution(workflow, {}, registry);
|
|
710
|
+
|
|
711
|
+
expect(performance.now() - started).toBeGreaterThanOrEqual(290);
|
|
712
|
+
expect((await getWorkflowRun(runId))!.status).toBe("completed");
|
|
713
|
+
}, 5_000);
|
|
714
|
+
});
|
|
715
|
+
|
|
601
716
|
describe("Validation", () => {
|
|
602
717
|
test("validation pass allows step to complete", async () => {
|
|
603
718
|
const registry = createTestRegistry();
|