@desplega.ai/agent-swarm 2.5.3 → 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-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
- package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
- package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
- package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
- package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
- package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
- package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
- package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
- package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
- package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
- package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
- package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
- package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
- package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
- 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-s53vp709.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
- package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
- package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
- package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
- package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
- package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
- package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
- package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
- package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +18 -18
- package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
- package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
- package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
- package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
- package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
- package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
- package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
- package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
- package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
- package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
- package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
- package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
- package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
- package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
- package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
- package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
- package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
- package/openapi.json +68 -3
- package/package.json +5 -5
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/db/approvals.ts +48 -0
- 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 +318 -7
- package/src/be/db.ts +67 -17
- 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 +2 -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/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/config.ts +48 -16
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/workflows.ts +129 -24
- 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/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-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/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/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/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 +3 -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 +37 -0
- 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/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +5 -1
- package/src/workflows/checkpoint.ts +6 -3
- package/src/workflows/engine.ts +30 -8
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +6 -1
- package/src/workflows/resume.ts +168 -56
- package/src/workflows/retry-poller.ts +49 -3
- 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/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/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import * as db from "../be/db";
|
|
5
|
+
import {
|
|
6
|
+
closeDb,
|
|
7
|
+
createTaskExtended,
|
|
8
|
+
createWorkflow,
|
|
9
|
+
createWorkflowRun,
|
|
10
|
+
createWorkflowRunStep,
|
|
11
|
+
getDbClient,
|
|
12
|
+
getTaskById,
|
|
13
|
+
getWaitStateByStepId,
|
|
14
|
+
getWorkflowRun,
|
|
15
|
+
getWorkflowRunStep,
|
|
16
|
+
getWorkflowRunStepsByRunId,
|
|
17
|
+
initDb,
|
|
18
|
+
startTask,
|
|
19
|
+
} from "../be/db";
|
|
20
|
+
import * as workflowRuns from "../be/db/workflow-runs";
|
|
21
|
+
import {
|
|
22
|
+
getRunLineageBlock,
|
|
23
|
+
getWorkflowRunTreeIds,
|
|
24
|
+
WORKFLOW_RUN_TREE_MAX_DEPTH,
|
|
25
|
+
WorkflowRunTreeError,
|
|
26
|
+
WorkflowStepNotExecutableError,
|
|
27
|
+
} from "../be/db/workflow-runs";
|
|
28
|
+
import { handleApprovalRequests } from "../http/approval-requests";
|
|
29
|
+
import * as slackApp from "../slack/app";
|
|
30
|
+
import type { AgentTask, Workflow, WorkflowDefinition, WorkflowRun } from "../types";
|
|
31
|
+
import * as approvalNotifications from "../workflows/approval-notifications";
|
|
32
|
+
import { startWorkflowExecution } from "../workflows/engine";
|
|
33
|
+
import { workflowEventBus } from "../workflows/event-bus";
|
|
34
|
+
import { AgentTaskExecutor } from "../workflows/executors/agent-task";
|
|
35
|
+
import type { ExecutorDependencies, ExecutorInput } from "../workflows/executors/base";
|
|
36
|
+
import { createExecutorRegistry } from "../workflows/executors/registry";
|
|
37
|
+
import { MAX_SUB_WORKFLOW_DEPTH, SubWorkflowExecutor } from "../workflows/executors/sub-workflow";
|
|
38
|
+
import { recoverIncompleteRuns } from "../workflows/recovery";
|
|
39
|
+
import {
|
|
40
|
+
cancelWorkflowRun,
|
|
41
|
+
finalizeOrWait,
|
|
42
|
+
resumeFromChildRun,
|
|
43
|
+
resumeWaitState,
|
|
44
|
+
retryFailedRun,
|
|
45
|
+
setupWorkflowResumeListener,
|
|
46
|
+
} from "../workflows/resume";
|
|
47
|
+
import { startRetryPoller, stopRetryPoller } from "../workflows/retry-poller";
|
|
48
|
+
import {
|
|
49
|
+
checkpointPortStepAndResolveSuccessors,
|
|
50
|
+
completeTaskStepAndResolveSuccessors,
|
|
51
|
+
scheduleTaskStepRetry,
|
|
52
|
+
} from "../workflows/task-step-routing";
|
|
53
|
+
import { listenOnFreePort } from "./test-net";
|
|
54
|
+
|
|
55
|
+
const TEST_DB_PATH = "./test-workflow-cascade-cancel.sqlite";
|
|
56
|
+
|
|
57
|
+
const deps: ExecutorDependencies = {
|
|
58
|
+
db,
|
|
59
|
+
eventBus: workflowEventBus,
|
|
60
|
+
interpolate: (t: string) => t,
|
|
61
|
+
};
|
|
62
|
+
const registry = createExecutorRegistry(deps);
|
|
63
|
+
let teardown: (() => void) | undefined;
|
|
64
|
+
|
|
65
|
+
beforeAll(() => {
|
|
66
|
+
initDb(TEST_DB_PATH);
|
|
67
|
+
teardown = setupWorkflowResumeListener(workflowEventBus, registry);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
stopRetryPoller();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
teardown?.();
|
|
76
|
+
closeDb();
|
|
77
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
78
|
+
await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── Fixtures ────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async function makeWorkflow(def: WorkflowDefinition): Promise<Workflow> {
|
|
85
|
+
return createWorkflow({ name: `wf-${crypto.randomUUID()}`, definition: def });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Parks on a long time wait, so the run stays `waiting`. */
|
|
89
|
+
const waitingDef: WorkflowDefinition = {
|
|
90
|
+
nodes: [{ id: "pause", type: "wait", config: { mode: "time", durationMs: 3_600_000 } }],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Dispatches one agent task and waits for it. */
|
|
94
|
+
const taskDef: WorkflowDefinition = {
|
|
95
|
+
nodes: [{ id: "work", type: "agent-task", config: { template: "do the work" } }],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Asks for one approval and waits for it. */
|
|
99
|
+
const approvalDef: WorkflowDefinition = {
|
|
100
|
+
nodes: [
|
|
101
|
+
{
|
|
102
|
+
id: "review",
|
|
103
|
+
type: "human-in-the-loop",
|
|
104
|
+
config: {
|
|
105
|
+
title: "Ship it?",
|
|
106
|
+
questions: [{ id: "ok", type: "approval", label: "Ship?" }],
|
|
107
|
+
approvers: { policy: "any" },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** One `sub-workflow` entry node per child workflow, so siblings start in parallel. */
|
|
114
|
+
function parentOf(...childIds: string[]): WorkflowDefinition {
|
|
115
|
+
return {
|
|
116
|
+
nodes: childIds.map((workflowId, i) => ({
|
|
117
|
+
id: `child${i}`,
|
|
118
|
+
type: "sub-workflow",
|
|
119
|
+
config: { workflowId, inputs: {} },
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean): Promise<T> {
|
|
125
|
+
for (let i = 0; i < 250; i++) {
|
|
126
|
+
const value = await read();
|
|
127
|
+
if (done(value)) return value;
|
|
128
|
+
await Bun.sleep(20);
|
|
129
|
+
}
|
|
130
|
+
throw new Error("condition not reached");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function runsOf(workflowId: string): Promise<WorkflowRun[]> {
|
|
134
|
+
const rows = await getDbClient().query<{ id: string }>(
|
|
135
|
+
"SELECT id FROM workflow_runs WHERE workflowId = ? ORDER BY rowid",
|
|
136
|
+
[workflowId],
|
|
137
|
+
);
|
|
138
|
+
const runs: WorkflowRun[] = [];
|
|
139
|
+
for (const row of rows) runs.push((await getWorkflowRun(row.id, "display"))!);
|
|
140
|
+
return runs;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The one run of `workflowId`, once it reaches `status`. */
|
|
144
|
+
async function runOf(workflowId: string, status: string): Promise<WorkflowRun> {
|
|
145
|
+
const [run] = await waitFor(
|
|
146
|
+
() => runsOf(workflowId),
|
|
147
|
+
(runs) => runs.length === 1 && runs[0]!.status === status,
|
|
148
|
+
);
|
|
149
|
+
return run!;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function status(runId: string): Promise<string | undefined> {
|
|
153
|
+
return (await getWorkflowRun(runId, "display"))?.status;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function tasksOfRun(runId: string): Promise<AgentTask[]> {
|
|
157
|
+
const rows = await getDbClient().query<{ id: string }>(
|
|
158
|
+
"SELECT id FROM agent_tasks WHERE workflowRunId = ? ORDER BY rowid",
|
|
159
|
+
[runId],
|
|
160
|
+
);
|
|
161
|
+
const tasks: AgentTask[] = [];
|
|
162
|
+
for (const row of rows) tasks.push((await getTaskById(row.id))!);
|
|
163
|
+
return tasks;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Raw status write: no events, as a crash or an older release left the row. */
|
|
167
|
+
async function setStatus(
|
|
168
|
+
table: "workflow_runs" | "workflow_run_steps",
|
|
169
|
+
id: string,
|
|
170
|
+
value: string,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
await getDbClient().run(`UPDATE ${table} SET status = ? WHERE id = ?`, [value, id]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A run row with a stored parent link, for trees the engine cannot be driven
|
|
177
|
+
* into deterministically (historical attempts, state an older release left).
|
|
178
|
+
*/
|
|
179
|
+
async function linkedRun(input: {
|
|
180
|
+
workflowId: string;
|
|
181
|
+
status: string;
|
|
182
|
+
parentStepId?: string;
|
|
183
|
+
parentStepAttempt?: number;
|
|
184
|
+
error?: string;
|
|
185
|
+
}): Promise<string> {
|
|
186
|
+
const id = crypto.randomUUID();
|
|
187
|
+
await createWorkflowRun({
|
|
188
|
+
id,
|
|
189
|
+
workflowId: input.workflowId,
|
|
190
|
+
parentStepId: input.parentStepId,
|
|
191
|
+
parentStepAttempt: input.parentStepAttempt,
|
|
192
|
+
});
|
|
193
|
+
await getDbClient().run("UPDATE workflow_runs SET status = ?, error = ? WHERE id = ?", [
|
|
194
|
+
input.status,
|
|
195
|
+
input.error ?? null,
|
|
196
|
+
id,
|
|
197
|
+
]);
|
|
198
|
+
return id;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function linkedStep(
|
|
202
|
+
runId: string,
|
|
203
|
+
input: { nodeId: string; nodeType: string; status: string; retryCount?: number },
|
|
204
|
+
): Promise<string> {
|
|
205
|
+
const id = crypto.randomUUID();
|
|
206
|
+
await createWorkflowRunStep({ id, runId, nodeId: input.nodeId, nodeType: input.nodeType });
|
|
207
|
+
await getDbClient().run("UPDATE workflow_run_steps SET status = ?, retryCount = ? WHERE id = ?", [
|
|
208
|
+
input.status,
|
|
209
|
+
input.retryCount ?? 0,
|
|
210
|
+
id,
|
|
211
|
+
]);
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** How many write (non read-only) transactions `fn` opened. */
|
|
216
|
+
async function writeTransactionsDuring(fn: () => Promise<unknown>): Promise<number> {
|
|
217
|
+
const client = getDbClient();
|
|
218
|
+
const realTransaction = client.transaction.bind(client);
|
|
219
|
+
let writes = 0;
|
|
220
|
+
const txSpy = spyOn(client, "transaction").mockImplementation(((txFn, opts) => {
|
|
221
|
+
if (!opts?.readOnly) writes++;
|
|
222
|
+
return realTransaction(txFn, opts);
|
|
223
|
+
}) as typeof client.transaction);
|
|
224
|
+
try {
|
|
225
|
+
await fn();
|
|
226
|
+
} finally {
|
|
227
|
+
txSpy.mockRestore();
|
|
228
|
+
}
|
|
229
|
+
return writes;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type Barrier = ReturnType<typeof barrier>;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Holds gated code: `entered` resolves when the code reaches the barrier,
|
|
236
|
+
* `release` lets it go on, and `finished` resolves once it returned.
|
|
237
|
+
*/
|
|
238
|
+
function barrier() {
|
|
239
|
+
const entered = Promise.withResolvers<void>();
|
|
240
|
+
const released = Promise.withResolvers<void>();
|
|
241
|
+
const finished = Promise.withResolvers<void>();
|
|
242
|
+
return {
|
|
243
|
+
entered: entered.promise,
|
|
244
|
+
finished: finished.promise,
|
|
245
|
+
release: () => released.resolve(),
|
|
246
|
+
async around<T>(fn: () => Promise<T>): Promise<T> {
|
|
247
|
+
entered.resolve();
|
|
248
|
+
await released.promise;
|
|
249
|
+
try {
|
|
250
|
+
return await fn();
|
|
251
|
+
} finally {
|
|
252
|
+
finished.resolve();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A registry whose `sub-workflow` executor (for one child workflow) or
|
|
260
|
+
* `agent-task` executor stops at a barrier before doing its work.
|
|
261
|
+
*/
|
|
262
|
+
function gatedRegistry(gates: {
|
|
263
|
+
subWorkflowOf?: string;
|
|
264
|
+
subWorkflow?: Barrier;
|
|
265
|
+
agentTask?: Barrier;
|
|
266
|
+
}) {
|
|
267
|
+
const gated = createExecutorRegistry(deps);
|
|
268
|
+
class GatedSubWorkflow extends SubWorkflowExecutor {
|
|
269
|
+
override async run(input: ExecutorInput) {
|
|
270
|
+
const target = (input.config as { workflowId?: string }).workflowId;
|
|
271
|
+
if (gates.subWorkflow && target === gates.subWorkflowOf) {
|
|
272
|
+
return gates.subWorkflow.around(() => super.run(input));
|
|
273
|
+
}
|
|
274
|
+
return super.run(input);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
class GatedAgentTask extends AgentTaskExecutor {
|
|
278
|
+
override async run(input: ExecutorInput) {
|
|
279
|
+
if (gates.agentTask) return gates.agentTask.around(() => super.run(input));
|
|
280
|
+
return super.run(input);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
gated.register(new GatedSubWorkflow(deps, gated));
|
|
284
|
+
gated.register(new GatedAgentTask(deps));
|
|
285
|
+
return gated;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Cascade ─────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
describe("cascade cancel", () => {
|
|
291
|
+
test("cancels root, child and grandchild with their steps and tasks", async () => {
|
|
292
|
+
const grandchild = await makeWorkflow(taskDef);
|
|
293
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
294
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
295
|
+
|
|
296
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
297
|
+
const childRun = await runOf(child.id, "waiting");
|
|
298
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
299
|
+
const [task] = await waitFor(
|
|
300
|
+
() => tasksOfRun(grandchildRun.id),
|
|
301
|
+
(tasks) => tasks.length === 1,
|
|
302
|
+
);
|
|
303
|
+
expect(task!.status).toBe("unassigned");
|
|
304
|
+
|
|
305
|
+
await cancelWorkflowRun(rootId, "stop the tree");
|
|
306
|
+
|
|
307
|
+
for (const runId of [rootId, childRun.id, grandchildRun.id]) {
|
|
308
|
+
const run = await getWorkflowRun(runId, "display");
|
|
309
|
+
expect(run?.status).toBe("cancelled");
|
|
310
|
+
expect(run?.error).toBe("stop the tree");
|
|
311
|
+
for (const step of await getWorkflowRunStepsByRunId(runId, "display")) {
|
|
312
|
+
expect(step.status).toBe("cancelled");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
expect((await getTaskById(task!.id))?.status).toBe("cancelled");
|
|
316
|
+
// A cancelled task is never handed to a worker.
|
|
317
|
+
expect(await startTask(task!.id)).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("fails tasks that depend on a cancelled tree task, and only those", async () => {
|
|
321
|
+
const child = await makeWorkflow(taskDef);
|
|
322
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
323
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
324
|
+
const childRun = await runOf(child.id, "waiting");
|
|
325
|
+
const [task] = await waitFor(
|
|
326
|
+
() => tasksOfRun(childRun.id),
|
|
327
|
+
(tasks) => tasks.length === 1,
|
|
328
|
+
);
|
|
329
|
+
const dependent = await createTaskExtended("after the step task", {
|
|
330
|
+
source: "mcp",
|
|
331
|
+
dependsOn: [task!.id],
|
|
332
|
+
});
|
|
333
|
+
const unrelated = await createTaskExtended("unrelated", { source: "mcp" });
|
|
334
|
+
|
|
335
|
+
await cancelWorkflowRun(rootId);
|
|
336
|
+
|
|
337
|
+
expect((await getTaskById(task!.id))?.status).toBe("cancelled");
|
|
338
|
+
expect((await getTaskById(dependent.id))?.status).toBe("failed");
|
|
339
|
+
expect((await getTaskById(unrelated.id))?.status).toBe(unrelated.status);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("cancels every sibling child and leaves independent runs alone", async () => {
|
|
343
|
+
const childA = await makeWorkflow(waitingDef);
|
|
344
|
+
const childB = await makeWorkflow(waitingDef);
|
|
345
|
+
const root = await makeWorkflow(parentOf(childA.id, childB.id));
|
|
346
|
+
const independent = await makeWorkflow(waitingDef);
|
|
347
|
+
|
|
348
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
349
|
+
const runA = await runOf(childA.id, "waiting");
|
|
350
|
+
const runB = await runOf(childB.id, "waiting");
|
|
351
|
+
const otherId = await startWorkflowExecution(independent, {}, registry);
|
|
352
|
+
|
|
353
|
+
await cancelWorkflowRun(rootId);
|
|
354
|
+
|
|
355
|
+
expect(await status(rootId)).toBe("cancelled");
|
|
356
|
+
expect(await status(runA.id)).toBe("cancelled");
|
|
357
|
+
expect(await status(runB.id)).toBe("cancelled");
|
|
358
|
+
expect(await status(otherId)).toBe("waiting");
|
|
359
|
+
|
|
360
|
+
// The independent run still runs to completion.
|
|
361
|
+
const [step] = await getWorkflowRunStepsByRunId(otherId);
|
|
362
|
+
const wait = await getWaitStateByStepId(step!.id);
|
|
363
|
+
await resumeWaitState(wait!.id, "fired", undefined, registry);
|
|
364
|
+
expect(await status(otherId)).toBe("completed");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("reaches children of every attempt, through a terminal run, and keeps terminal history", async () => {
|
|
368
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
369
|
+
const rootId = await linkedRun({ workflowId: wf.id, status: "waiting" });
|
|
370
|
+
const parentStep = await linkedStep(rootId, {
|
|
371
|
+
nodeId: "child",
|
|
372
|
+
nodeType: "sub-workflow",
|
|
373
|
+
status: "waiting",
|
|
374
|
+
retryCount: 1,
|
|
375
|
+
});
|
|
376
|
+
// Attempt 0's child failed, but still has a live child of its own.
|
|
377
|
+
// Attempt 1's child is the current one.
|
|
378
|
+
const oldChild = await linkedRun({
|
|
379
|
+
workflowId: wf.id,
|
|
380
|
+
status: "failed",
|
|
381
|
+
error: "attempt 0 failed",
|
|
382
|
+
parentStepId: parentStep,
|
|
383
|
+
parentStepAttempt: 0,
|
|
384
|
+
});
|
|
385
|
+
const oldChildStep = await linkedStep(oldChild, {
|
|
386
|
+
nodeId: "inner",
|
|
387
|
+
nodeType: "sub-workflow",
|
|
388
|
+
status: "waiting",
|
|
389
|
+
});
|
|
390
|
+
const doneStep = await linkedStep(oldChild, {
|
|
391
|
+
nodeId: "before",
|
|
392
|
+
nodeType: "script",
|
|
393
|
+
status: "completed",
|
|
394
|
+
});
|
|
395
|
+
const orphan = await linkedRun({
|
|
396
|
+
workflowId: wf.id,
|
|
397
|
+
status: "waiting",
|
|
398
|
+
parentStepId: oldChildStep,
|
|
399
|
+
});
|
|
400
|
+
const currentChild = await linkedRun({
|
|
401
|
+
workflowId: wf.id,
|
|
402
|
+
status: "running",
|
|
403
|
+
parentStepId: parentStep,
|
|
404
|
+
parentStepAttempt: 1,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await cancelWorkflowRun(rootId, "stop");
|
|
408
|
+
|
|
409
|
+
expect(await status(rootId)).toBe("cancelled");
|
|
410
|
+
expect(await status(currentChild)).toBe("cancelled");
|
|
411
|
+
expect(await status(orphan)).toBe("cancelled");
|
|
412
|
+
// The terminal run keeps its status and reason; only its open step goes.
|
|
413
|
+
const old = await getWorkflowRun(oldChild, "display");
|
|
414
|
+
expect(old?.status).toBe("failed");
|
|
415
|
+
expect(old?.error).toBe("attempt 0 failed");
|
|
416
|
+
expect((await getWorkflowRunStep(oldChildStep))?.status).toBe("cancelled");
|
|
417
|
+
expect((await getWorkflowRunStep(doneStep))?.status).toBe("completed");
|
|
418
|
+
expect((await getWorkflowRunStep(parentStep))?.retryCount).toBe(1);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("a repeat cancel of a settled tree only reads: no write transaction, scan or task cancel", async () => {
|
|
422
|
+
const grandchild = await makeWorkflow(taskDef);
|
|
423
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
424
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
425
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
426
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
427
|
+
await waitFor(
|
|
428
|
+
() => tasksOfRun(grandchildRun.id),
|
|
429
|
+
(tasks) => tasks.length === 1,
|
|
430
|
+
);
|
|
431
|
+
await cancelWorkflowRun(rootId, "first");
|
|
432
|
+
|
|
433
|
+
const scanSpy = spyOn(workflowRuns, "getTaskIdsWithDependents");
|
|
434
|
+
const cancelSpy = spyOn(db, "cancelTask");
|
|
435
|
+
let writes: number;
|
|
436
|
+
try {
|
|
437
|
+
writes = await writeTransactionsDuring(async () => {
|
|
438
|
+
for (let i = 0; i < 3; i++) await cancelWorkflowRun(rootId, `repeat ${i}`);
|
|
439
|
+
});
|
|
440
|
+
} finally {
|
|
441
|
+
scanSpy.mockRestore();
|
|
442
|
+
cancelSpy.mockRestore();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
expect(writes).toBe(0);
|
|
446
|
+
expect(scanSpy).not.toHaveBeenCalled();
|
|
447
|
+
expect(cancelSpy).not.toHaveBeenCalled();
|
|
448
|
+
expect((await getWorkflowRun(grandchildRun.id, "display"))?.error).toBe("first");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("an approval linked to a cancelled tree is born cancelled, so a repeat cancel stays read-only", async () => {
|
|
452
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
453
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
454
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
455
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
456
|
+
const childRun = await runOf(child.id, "waiting");
|
|
457
|
+
await runOf(grandchild.id, "waiting");
|
|
458
|
+
await cancelWorkflowRun(rootId, "stop");
|
|
459
|
+
// A run an older release left live under the cancelled root.
|
|
460
|
+
const legacy = await linkedRun({
|
|
461
|
+
workflowId: child.id,
|
|
462
|
+
status: "waiting",
|
|
463
|
+
parentStepId: (await getWorkflowRunStepsByRunId(childRun.id))[0]!.id,
|
|
464
|
+
parentStepAttempt: 7,
|
|
465
|
+
});
|
|
466
|
+
const ask = (workflowRunId: string) =>
|
|
467
|
+
db.createApprovalRequest({
|
|
468
|
+
id: crypto.randomUUID(),
|
|
469
|
+
title: "Late ask",
|
|
470
|
+
questions: [{ id: "q1", type: "approval", label: "Approve?" }],
|
|
471
|
+
approvers: { policy: "any" },
|
|
472
|
+
workflowRunId,
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// As POST /api/approval-requests does: a run link, no step, no HITL flag.
|
|
476
|
+
const onCancelled = await ask(childRun.id);
|
|
477
|
+
const onLegacy = await ask(legacy);
|
|
478
|
+
|
|
479
|
+
expect(onCancelled.status).toBe("cancelled");
|
|
480
|
+
expect(onCancelled.resolutionReason).toBe("stop");
|
|
481
|
+
expect(onLegacy.status).toBe("cancelled");
|
|
482
|
+
// The nearest cancelled ancestor is named.
|
|
483
|
+
expect(onLegacy.resolutionReason).toBe(`Ancestor workflow run ${childRun.id} is cancelled`);
|
|
484
|
+
|
|
485
|
+
// The legacy live run is open work: one cancel closes it, then repeats only read.
|
|
486
|
+
await cancelWorkflowRun(rootId);
|
|
487
|
+
expect(await status(legacy)).toBe("cancelled");
|
|
488
|
+
await ask(childRun.id);
|
|
489
|
+
expect(await writeTransactionsDuring(() => cancelWorkflowRun(rootId))).toBe(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("POST /api/approval-requests on a cancelled tree creates no open work (HTTP)", async () => {
|
|
493
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
494
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
495
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
496
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
497
|
+
const childRun = await runOf(child.id, "waiting");
|
|
498
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
499
|
+
await cancelWorkflowRun(rootId, "stop");
|
|
500
|
+
|
|
501
|
+
const server = createServer(async (req, res) => {
|
|
502
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
503
|
+
const handled = await handleApprovalRequests(
|
|
504
|
+
req,
|
|
505
|
+
res,
|
|
506
|
+
url.pathname.split("/").filter(Boolean),
|
|
507
|
+
url.searchParams,
|
|
508
|
+
);
|
|
509
|
+
if (!handled) {
|
|
510
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
511
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
const port = await listenOnFreePort(server);
|
|
515
|
+
try {
|
|
516
|
+
// A run link and no step, as an agent or script asks through the API.
|
|
517
|
+
for (const workflowRunId of [rootId, childRun.id, grandchildRun.id]) {
|
|
518
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/approval-requests`, {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: { "Content-Type": "application/json" },
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
title: "Late ask",
|
|
523
|
+
questions: [{ id: "q1", type: "approval", label: "Approve?", required: true }],
|
|
524
|
+
approvers: { policy: "any" },
|
|
525
|
+
workflowRunId,
|
|
526
|
+
}),
|
|
527
|
+
});
|
|
528
|
+
expect(response.status).toBe(201);
|
|
529
|
+
const { approvalRequest } = (await response.json()) as {
|
|
530
|
+
approvalRequest: { status: string; resolutionReason: string | null };
|
|
531
|
+
};
|
|
532
|
+
expect(approvalRequest.status).toBe("cancelled");
|
|
533
|
+
expect(approvalRequest.resolutionReason).toBe("stop");
|
|
534
|
+
}
|
|
535
|
+
} finally {
|
|
536
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const pending = await getDbClient().get<{ n: number }>(
|
|
540
|
+
"SELECT COUNT(*) AS n FROM approval_requests WHERE workflowRunId IN (?, ?, ?) AND status = 'pending'",
|
|
541
|
+
[rootId, childRun.id, grandchildRun.id],
|
|
542
|
+
);
|
|
543
|
+
expect(pending?.n).toBe(0);
|
|
544
|
+
expect(await writeTransactionsDuring(() => cancelWorkflowRun(rootId))).toBe(0);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("an approval linked to a live run without a step stays pending", async () => {
|
|
548
|
+
const run = await makeWorkflow(waitingDef);
|
|
549
|
+
const runId = await startWorkflowExecution(run, {}, registry);
|
|
550
|
+
const created = await db.createApprovalRequest({
|
|
551
|
+
id: crypto.randomUUID(),
|
|
552
|
+
title: "Ask",
|
|
553
|
+
questions: [{ id: "q1", type: "approval", label: "Approve?" }],
|
|
554
|
+
approvers: { policy: "any" },
|
|
555
|
+
workflowRunId: runId,
|
|
556
|
+
});
|
|
557
|
+
expect(created.status).toBe("pending");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("concurrent repeat cancels of one run share a single tree walk", async () => {
|
|
561
|
+
const child = await makeWorkflow(waitingDef);
|
|
562
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
563
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
564
|
+
await runOf(child.id, "waiting");
|
|
565
|
+
await cancelWorkflowRun(rootId);
|
|
566
|
+
|
|
567
|
+
const walkSpy = spyOn(workflowRuns, "getWorkflowRunTreeIds");
|
|
568
|
+
try {
|
|
569
|
+
await Promise.all(Array.from({ length: 5 }, () => cancelWorkflowRun(rootId)));
|
|
570
|
+
// The settled probe walks the tree once for all five calls.
|
|
571
|
+
expect(walkSpy).toHaveBeenCalledTimes(1);
|
|
572
|
+
// A later call is a new cancel, not the finished one.
|
|
573
|
+
await cancelWorkflowRun(rootId);
|
|
574
|
+
expect(walkSpy).toHaveBeenCalledTimes(2);
|
|
575
|
+
} finally {
|
|
576
|
+
walkSpy.mockRestore();
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("a repeat cancel of a cancelled root finishes descendants an older cancel left", async () => {
|
|
581
|
+
const child = await makeWorkflow(waitingDef);
|
|
582
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
583
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
584
|
+
const childRun = await runOf(child.id, "waiting");
|
|
585
|
+
// A cancel from before cascading: the root only.
|
|
586
|
+
await getDbClient().run(
|
|
587
|
+
"UPDATE workflow_runs SET status = 'cancelled', error = 'first cancel' WHERE id = ?",
|
|
588
|
+
[rootId],
|
|
589
|
+
);
|
|
590
|
+
for (const step of await getWorkflowRunStepsByRunId(rootId)) {
|
|
591
|
+
await setStatus("workflow_run_steps", step.id, "cancelled");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await cancelWorkflowRun(rootId, "second cancel");
|
|
595
|
+
await cancelWorkflowRun(rootId, "third cancel");
|
|
596
|
+
|
|
597
|
+
const rootRun = await getWorkflowRun(rootId, "display");
|
|
598
|
+
expect(rootRun?.status).toBe("cancelled");
|
|
599
|
+
expect(rootRun?.error).toBe("first cancel");
|
|
600
|
+
const childAfter = await getWorkflowRun(childRun.id, "display");
|
|
601
|
+
expect(childAfter?.status).toBe("cancelled");
|
|
602
|
+
expect(childAfter?.error).toBe("second cancel");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("concurrent cancels of a root and its child both succeed and keep one reason per run", async () => {
|
|
606
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
607
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
608
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
609
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
610
|
+
const childRun = await runOf(child.id, "waiting");
|
|
611
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
612
|
+
|
|
613
|
+
await Promise.all([
|
|
614
|
+
cancelWorkflowRun(rootId, "root reason"),
|
|
615
|
+
cancelWorkflowRun(childRun.id, "child reason"),
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
expect((await getWorkflowRun(rootId, "display"))?.error).toBe("root reason");
|
|
619
|
+
const childAfter = await getWorkflowRun(childRun.id, "display");
|
|
620
|
+
expect(childAfter?.status).toBe("cancelled");
|
|
621
|
+
expect(["root reason", "child reason"]).toContain(childAfter?.error);
|
|
622
|
+
// Whichever cancel committed first covered the child's whole subtree.
|
|
623
|
+
const grandchildAfter = await getWorkflowRun(grandchildRun.id, "display");
|
|
624
|
+
expect(grandchildAfter?.status).toBe("cancelled");
|
|
625
|
+
expect(grandchildAfter?.error).toBe(childAfter?.error);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("a completed, failed or skipped root still refuses the cancel", async () => {
|
|
629
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
630
|
+
for (const terminal of ["completed", "failed", "skipped"]) {
|
|
631
|
+
const rootId = await linkedRun({ workflowId: wf.id, status: terminal });
|
|
632
|
+
const step = await linkedStep(rootId, {
|
|
633
|
+
nodeId: "child",
|
|
634
|
+
nodeType: "sub-workflow",
|
|
635
|
+
status: "completed",
|
|
636
|
+
});
|
|
637
|
+
const childId = await linkedRun({ workflowId: wf.id, status: "waiting", parentStepId: step });
|
|
638
|
+
|
|
639
|
+
await expect(cancelWorkflowRun(rootId)).rejects.toThrow(`'${terminal}' state`);
|
|
640
|
+
expect(await status(childId)).toBe("waiting");
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// ─── Atomicity ───────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
describe("cascade cancel atomicity", () => {
|
|
648
|
+
test("a failure partway rolls the whole tree back", async () => {
|
|
649
|
+
const grandchild = await makeWorkflow(taskDef);
|
|
650
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
651
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
652
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
653
|
+
const childRun = await runOf(child.id, "waiting");
|
|
654
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
655
|
+
const [task] = await waitFor(
|
|
656
|
+
() => tasksOfRun(grandchildRun.id),
|
|
657
|
+
(tasks) => tasks.length === 1,
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
// The grandchild's task is cancelled after the root and child rows were written.
|
|
661
|
+
const original = db.cancelTask;
|
|
662
|
+
const cancelSpy = spyOn(db, "cancelTask").mockImplementation(async (id, reason) => {
|
|
663
|
+
if (id === task!.id) throw new Error("injected cancel failure");
|
|
664
|
+
return original(id, reason);
|
|
665
|
+
});
|
|
666
|
+
try {
|
|
667
|
+
await expect(cancelWorkflowRun(rootId)).rejects.toThrow("injected cancel failure");
|
|
668
|
+
} finally {
|
|
669
|
+
cancelSpy.mockRestore();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
for (const runId of [rootId, childRun.id, grandchildRun.id]) {
|
|
673
|
+
expect(await status(runId)).toBe("waiting");
|
|
674
|
+
for (const step of await getWorkflowRunStepsByRunId(runId)) {
|
|
675
|
+
expect(step.status).toBe("waiting");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
expect((await getTaskById(task!.id))?.status).toBe("unassigned");
|
|
679
|
+
|
|
680
|
+
// Nothing was half-applied, so the next cancel covers everything.
|
|
681
|
+
await cancelWorkflowRun(rootId);
|
|
682
|
+
expect(await status(grandchildRun.id)).toBe("cancelled");
|
|
683
|
+
expect((await getTaskById(task!.id))?.status).toBe("cancelled");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("a cycle in the stored links refuses the cancel and writes nothing", async () => {
|
|
687
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
688
|
+
const a = await linkedRun({ workflowId: wf.id, status: "waiting" });
|
|
689
|
+
const aStep = await linkedStep(a, { nodeId: "s", nodeType: "sub-workflow", status: "waiting" });
|
|
690
|
+
const b = await linkedRun({ workflowId: wf.id, status: "waiting", parentStepId: aStep });
|
|
691
|
+
const bStep = await linkedStep(b, { nodeId: "s", nodeType: "sub-workflow", status: "waiting" });
|
|
692
|
+
await getDbClient().run("UPDATE workflow_runs SET parentStepId = ? WHERE id = ?", [bStep, a]);
|
|
693
|
+
|
|
694
|
+
await expect(cancelWorkflowRun(a)).rejects.toThrow("cycle");
|
|
695
|
+
expect(await status(a)).toBe("waiting");
|
|
696
|
+
expect(await status(b)).toBe("waiting");
|
|
697
|
+
expect((await getWorkflowRunStep(aStep))?.status).toBe("waiting");
|
|
698
|
+
expect(await getRunLineageBlock(b)).toContain("cycle");
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("a chain past the depth bound is refused whole; the bound covers every admitted depth", async () => {
|
|
702
|
+
expect(WORKFLOW_RUN_TREE_MAX_DEPTH).toBeGreaterThanOrEqual(MAX_SUB_WORKFLOW_DEPTH);
|
|
703
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
704
|
+
const rootId = await linkedRun({ workflowId: wf.id, status: "waiting" });
|
|
705
|
+
let parentId = rootId;
|
|
706
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
707
|
+
const step = await linkedStep(parentId, {
|
|
708
|
+
nodeId: "s",
|
|
709
|
+
nodeType: "sub-workflow",
|
|
710
|
+
status: "waiting",
|
|
711
|
+
});
|
|
712
|
+
parentId = await linkedRun({ workflowId: wf.id, status: "waiting", parentStepId: step });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
expect(await getWorkflowRunTreeIds(rootId)).toHaveLength(4);
|
|
716
|
+
await expect(getWorkflowRunTreeIds(rootId, { maxDepth: 2 })).rejects.toBeInstanceOf(
|
|
717
|
+
WorkflowRunTreeError,
|
|
718
|
+
);
|
|
719
|
+
await expect(getWorkflowRunTreeIds(rootId, { maxDepth: 2 })).rejects.toThrow("deeper than 2");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test(
|
|
723
|
+
"a wide tree the engine admitted (21 children x 95 grandchildren) is cancelled whole",
|
|
724
|
+
async () => {
|
|
725
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
726
|
+
const child = await makeWorkflow(parentOf(...Array(95).fill(grandchild.id)));
|
|
727
|
+
const root = await makeWorkflow(parentOf(...Array(21).fill(child.id)));
|
|
728
|
+
const workflowIds = [root.id, child.id, grandchild.id];
|
|
729
|
+
const count = async (where: string, params: string[] = []) =>
|
|
730
|
+
(
|
|
731
|
+
await getDbClient().get<{ n: number }>(
|
|
732
|
+
`SELECT COUNT(*) AS n FROM workflow_runs WHERE workflowId IN (?, ?, ?) AND ${where}`,
|
|
733
|
+
[...workflowIds, ...params],
|
|
734
|
+
)
|
|
735
|
+
)?.n;
|
|
736
|
+
|
|
737
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
738
|
+
// Every child walks on its own; wait until all grandchildren parked.
|
|
739
|
+
await waitFor(
|
|
740
|
+
() => count("workflowId = ? AND status = 'waiting'", [grandchild.id]),
|
|
741
|
+
(n) => n === 21 * 95,
|
|
742
|
+
);
|
|
743
|
+
const tree = await getWorkflowRunTreeIds(rootId);
|
|
744
|
+
expect(tree).toHaveLength(1 + 21 + 21 * 95);
|
|
745
|
+
|
|
746
|
+
await cancelWorkflowRun(rootId, "stop the wide tree");
|
|
747
|
+
|
|
748
|
+
expect(await count("status IN ('running', 'waiting')")).toBe(0);
|
|
749
|
+
expect(await count("status = 'cancelled' AND error = 'stop the wide tree'")).toBe(
|
|
750
|
+
tree.length,
|
|
751
|
+
);
|
|
752
|
+
const openSteps = await getDbClient().get<{ n: number }>(
|
|
753
|
+
`SELECT COUNT(*) AS n FROM workflow_run_steps s
|
|
754
|
+
JOIN workflow_runs r ON r.id = s.runId
|
|
755
|
+
WHERE r.workflowId IN (?, ?, ?) AND s.status IN ('pending', 'running', 'waiting')`,
|
|
756
|
+
workflowIds,
|
|
757
|
+
);
|
|
758
|
+
expect(openSteps?.n).toBe(0);
|
|
759
|
+
},
|
|
760
|
+
{ timeout: 60_000 },
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
test("a repeat cancel retries a Slack post an outage dropped, and never repeats a delivered one", async () => {
|
|
764
|
+
const child = await makeWorkflow(approvalDef);
|
|
765
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
766
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
767
|
+
const childRun = await runOf(child.id, "waiting");
|
|
768
|
+
const [approval] = await waitFor(
|
|
769
|
+
() =>
|
|
770
|
+
getDbClient().query<{ id: string }>(
|
|
771
|
+
"SELECT id FROM approval_requests WHERE workflowRunId = ?",
|
|
772
|
+
[childRun.id],
|
|
773
|
+
),
|
|
774
|
+
(rows) => rows.length === 1,
|
|
775
|
+
);
|
|
776
|
+
// The approval card was posted to a Slack thread.
|
|
777
|
+
await getDbClient().run("UPDATE approval_requests SET notificationChannels = ? WHERE id = ?", [
|
|
778
|
+
JSON.stringify([{ channel: "slack", target: "C_REVIEW", messageTs: "1700000000.000100" }]),
|
|
779
|
+
approval!.id,
|
|
780
|
+
]);
|
|
781
|
+
|
|
782
|
+
let slackUp = false;
|
|
783
|
+
const posts: string[] = [];
|
|
784
|
+
const postMessage = async (input: { channel: string; thread_ts: string; text: string }) => {
|
|
785
|
+
if (!slackUp) throw new Error("slack outage");
|
|
786
|
+
posts.push(`${input.channel}:${input.thread_ts}:${input.text}`);
|
|
787
|
+
return { ok: true };
|
|
788
|
+
};
|
|
789
|
+
const slackSpy = spyOn(slackApp, "getSlackApp").mockReturnValue({
|
|
790
|
+
client: { chat: { postMessage } },
|
|
791
|
+
} as unknown as ReturnType<typeof slackApp.getSlackApp>);
|
|
792
|
+
try {
|
|
793
|
+
await cancelWorkflowRun(rootId, "stop");
|
|
794
|
+
expect(posts).toHaveLength(0);
|
|
795
|
+
expect(await status(childRun.id)).toBe("cancelled");
|
|
796
|
+
|
|
797
|
+
slackUp = true;
|
|
798
|
+
await cancelWorkflowRun(rootId, "again");
|
|
799
|
+
expect(posts).toEqual([
|
|
800
|
+
"C_REVIEW:1700000000.000100:This approval is no longer actionable: stop",
|
|
801
|
+
]);
|
|
802
|
+
|
|
803
|
+
await cancelWorkflowRun(rootId, "third");
|
|
804
|
+
expect(posts).toHaveLength(1);
|
|
805
|
+
} finally {
|
|
806
|
+
slackSpy.mockRestore();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("a failed notification after commit does not undo the cancel", async () => {
|
|
811
|
+
const child = await makeWorkflow(approvalDef);
|
|
812
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
813
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
814
|
+
const childRun = await runOf(child.id, "waiting");
|
|
815
|
+
const [approval] = await waitFor(
|
|
816
|
+
() =>
|
|
817
|
+
getDbClient().query<{ id: string }>(
|
|
818
|
+
"SELECT id FROM approval_requests WHERE workflowRunId = ?",
|
|
819
|
+
[childRun.id],
|
|
820
|
+
),
|
|
821
|
+
(rows) => rows.length === 1,
|
|
822
|
+
);
|
|
823
|
+
await getDbClient().run("UPDATE approval_requests SET notificationChannels = ? WHERE id = ?", [
|
|
824
|
+
JSON.stringify([{ channel: "slack", target: "C_REVIEW", messageTs: "1700000000.000200" }]),
|
|
825
|
+
approval!.id,
|
|
826
|
+
]);
|
|
827
|
+
|
|
828
|
+
const postSpy = spyOn(approvalNotifications, "postApprovalCancellationUpdates");
|
|
829
|
+
postSpy.mockRejectedValue(new Error("slack is down"));
|
|
830
|
+
try {
|
|
831
|
+
await cancelWorkflowRun(rootId, "stop");
|
|
832
|
+
expect(postSpy).toHaveBeenCalled();
|
|
833
|
+
} finally {
|
|
834
|
+
postSpy.mockRestore();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
expect(await status(rootId)).toBe("cancelled");
|
|
838
|
+
expect(await status(childRun.id)).toBe("cancelled");
|
|
839
|
+
expect((await db.getApprovalRequestById(approval!.id))?.status).toBe("cancelled");
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// ─── Late work ───────────────────────────────────────────────
|
|
844
|
+
|
|
845
|
+
describe("late work after a cascade cancel", () => {
|
|
846
|
+
test("a child run inserted after the cancel commits is refused", async () => {
|
|
847
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
848
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
849
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
850
|
+
const gate = barrier();
|
|
851
|
+
const gated = gatedRegistry({ subWorkflowOf: grandchild.id, subWorkflow: gate });
|
|
852
|
+
|
|
853
|
+
const rootId = await startWorkflowExecution(root, {}, gated);
|
|
854
|
+
// The child run's sub-workflow step is claimed and its executor is about
|
|
855
|
+
// to create the grandchild run.
|
|
856
|
+
await gate.entered;
|
|
857
|
+
const [childRun] = await runsOf(child.id);
|
|
858
|
+
|
|
859
|
+
await cancelWorkflowRun(rootId, "stop");
|
|
860
|
+
gate.release();
|
|
861
|
+
await gate.finished;
|
|
862
|
+
|
|
863
|
+
expect(await status(childRun!.id)).toBe("cancelled");
|
|
864
|
+
const [childStep] = await getWorkflowRunStepsByRunId(childRun!.id);
|
|
865
|
+
expect(childStep?.status).toBe("cancelled");
|
|
866
|
+
expect(await runsOf(grandchild.id)).toHaveLength(0);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("a child run inserted before the cancel commits is cancelled with the tree", async () => {
|
|
870
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
871
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
872
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
873
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
874
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
875
|
+
|
|
876
|
+
await cancelWorkflowRun(rootId);
|
|
877
|
+
|
|
878
|
+
expect(await status(grandchildRun.id)).toBe("cancelled");
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("a task the executor creates after the cancel commits is refused", async () => {
|
|
882
|
+
const gate = barrier();
|
|
883
|
+
const child = await makeWorkflow(taskDef);
|
|
884
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
885
|
+
|
|
886
|
+
const rootId = await startWorkflowExecution(root, {}, gatedRegistry({ agentTask: gate }));
|
|
887
|
+
await gate.entered;
|
|
888
|
+
const [childRun] = await runsOf(child.id);
|
|
889
|
+
const [step] = await getWorkflowRunStepsByRunId(childRun!.id);
|
|
890
|
+
expect(step?.status).toBe("running");
|
|
891
|
+
|
|
892
|
+
await cancelWorkflowRun(rootId);
|
|
893
|
+
gate.release();
|
|
894
|
+
await gate.finished;
|
|
895
|
+
|
|
896
|
+
expect((await getWorkflowRunStep(step!.id))?.status).toBe("cancelled");
|
|
897
|
+
expect(await tasksOfRun(childRun!.id)).toHaveLength(0);
|
|
898
|
+
await expect(
|
|
899
|
+
createTaskExtended("late", {
|
|
900
|
+
source: "workflow",
|
|
901
|
+
workflowRunId: childRun!.id,
|
|
902
|
+
workflowRunStepId: step!.id,
|
|
903
|
+
}),
|
|
904
|
+
).rejects.toBeInstanceOf(WorkflowStepNotExecutableError);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
test("callbacks that read the step before the cancel commit route nothing after it", async () => {
|
|
908
|
+
const child = await makeWorkflow({
|
|
909
|
+
nodes: [
|
|
910
|
+
{ id: "work", type: "agent-task", config: { template: "work" }, next: "after" },
|
|
911
|
+
{ id: "after", type: "wait", config: { mode: "time", durationMs: 3_600_000 } },
|
|
912
|
+
],
|
|
913
|
+
});
|
|
914
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
915
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
916
|
+
const childRun = await runOf(child.id, "waiting");
|
|
917
|
+
const [task] = await waitFor(
|
|
918
|
+
() => tasksOfRun(childRun.id),
|
|
919
|
+
(tasks) => tasks.length === 1,
|
|
920
|
+
);
|
|
921
|
+
// Each handler read the step as `waiting` before the cancel committed.
|
|
922
|
+
const [snapshot] = await getWorkflowRunStepsByRunId(childRun.id);
|
|
923
|
+
expect(snapshot?.status).toBe("waiting");
|
|
924
|
+
|
|
925
|
+
await cancelWorkflowRun(rootId);
|
|
926
|
+
|
|
927
|
+
const def = child.definition;
|
|
928
|
+
const completed = await completeTaskStepAndResolveSuccessors(
|
|
929
|
+
def,
|
|
930
|
+
childRun.id,
|
|
931
|
+
snapshot!,
|
|
932
|
+
{ taskId: task!.id, taskOutput: "late" },
|
|
933
|
+
{},
|
|
934
|
+
);
|
|
935
|
+
const ported = await checkpointPortStepAndResolveSuccessors(
|
|
936
|
+
def,
|
|
937
|
+
childRun.id,
|
|
938
|
+
snapshot!.id,
|
|
939
|
+
snapshot!.nodeId,
|
|
940
|
+
{},
|
|
941
|
+
"default",
|
|
942
|
+
{},
|
|
943
|
+
);
|
|
944
|
+
const retried = await scheduleTaskStepRetry(def, childRun.id, snapshot!, task!.id, "late");
|
|
945
|
+
await finalizeOrWait(childRun.id);
|
|
946
|
+
await recoverIncompleteRuns(registry);
|
|
947
|
+
|
|
948
|
+
expect(completed.claimed).toBe(false);
|
|
949
|
+
expect(ported.claimed).toBe(false);
|
|
950
|
+
expect(retried).not.toBe("scheduled");
|
|
951
|
+
expect(await status(childRun.id)).toBe("cancelled");
|
|
952
|
+
const steps = await getWorkflowRunStepsByRunId(childRun.id);
|
|
953
|
+
expect(steps.map((s) => [s.nodeId, s.status])).toEqual([["work", "cancelled"]]);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
test("a child that ends after the cancel settles nothing and queues no retry", async () => {
|
|
957
|
+
const child = await makeWorkflow(waitingDef);
|
|
958
|
+
const rootDef = parentOf(child.id);
|
|
959
|
+
rootDef.nodes[0]!.retry = { maxRetries: 3, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 };
|
|
960
|
+
const root = await makeWorkflow(rootDef);
|
|
961
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
962
|
+
const childRun = await runOf(child.id, "waiting");
|
|
963
|
+
const [parentStep] = await getWorkflowRunStepsByRunId(rootId);
|
|
964
|
+
|
|
965
|
+
await cancelWorkflowRun(rootId);
|
|
966
|
+
// The child's end lands late, as a failure that would normally retry.
|
|
967
|
+
await setStatus("workflow_runs", childRun.id, "failed");
|
|
968
|
+
expect(await resumeFromChildRun(parentStep!.id, registry)).toBe(false);
|
|
969
|
+
await recoverIncompleteRuns(registry);
|
|
970
|
+
|
|
971
|
+
const step = await getWorkflowRunStep(parentStep!.id);
|
|
972
|
+
expect(step?.status).toBe("cancelled");
|
|
973
|
+
expect(step?.retryCount).toBe(0);
|
|
974
|
+
expect(step?.nextRetryAt ?? null).toBeNull();
|
|
975
|
+
expect(await status(rootId)).toBe("cancelled");
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("a queued retry of a terminal descendant is dropped and never revives it", async () => {
|
|
979
|
+
const wf = await makeWorkflow({
|
|
980
|
+
nodes: [{ id: "flaky", type: "property-match", config: { conditions: [] } }],
|
|
981
|
+
});
|
|
982
|
+
const rootId = await linkedRun({ workflowId: wf.id, status: "waiting" });
|
|
983
|
+
const parentStep = await linkedStep(rootId, {
|
|
984
|
+
nodeId: "child",
|
|
985
|
+
nodeType: "sub-workflow",
|
|
986
|
+
status: "waiting",
|
|
987
|
+
});
|
|
988
|
+
const failedChild = await linkedRun({
|
|
989
|
+
workflowId: wf.id,
|
|
990
|
+
status: "failed",
|
|
991
|
+
error: "flaky failed",
|
|
992
|
+
parentStepId: parentStep,
|
|
993
|
+
});
|
|
994
|
+
const flakyStep = await linkedStep(failedChild, {
|
|
995
|
+
nodeId: "flaky",
|
|
996
|
+
nodeType: "property-match",
|
|
997
|
+
status: "failed",
|
|
998
|
+
});
|
|
999
|
+
const due = new Date(Date.now() - 1000).toISOString();
|
|
1000
|
+
const queueRetry = () =>
|
|
1001
|
+
getDbClient().run("UPDATE workflow_run_steps SET nextRetryAt = ? WHERE id = ?", [
|
|
1002
|
+
due,
|
|
1003
|
+
flakyStep,
|
|
1004
|
+
]);
|
|
1005
|
+
await queueRetry();
|
|
1006
|
+
|
|
1007
|
+
await cancelWorkflowRun(rootId);
|
|
1008
|
+
expect((await getWorkflowRunStep(flakyStep))?.nextRetryAt ?? null).toBeNull();
|
|
1009
|
+
|
|
1010
|
+
// A retry queued again (older state, a racing writer) is dropped by the claim.
|
|
1011
|
+
await queueRetry();
|
|
1012
|
+
startRetryPoller(registry, 10);
|
|
1013
|
+
const step = await waitFor(
|
|
1014
|
+
() => getWorkflowRunStep(flakyStep),
|
|
1015
|
+
(row) => row?.nextRetryAt == null,
|
|
1016
|
+
);
|
|
1017
|
+
stopRetryPoller();
|
|
1018
|
+
|
|
1019
|
+
expect(step?.status).toBe("failed");
|
|
1020
|
+
expect(step?.retryCount).toBe(0);
|
|
1021
|
+
const run = await getWorkflowRun(failedChild, "display");
|
|
1022
|
+
expect(run?.status).toBe("failed");
|
|
1023
|
+
expect(run?.error).toBe("flaky failed");
|
|
1024
|
+
// A manual retry of the failed descendant is refused too.
|
|
1025
|
+
await expect(retryFailedRun(failedChild, registry)).rejects.toThrow(
|
|
1026
|
+
`Ancestor workflow run ${rootId} is cancelled`,
|
|
1027
|
+
);
|
|
1028
|
+
expect(await status(failedChild)).toBe("failed");
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test("restart recovery after the commit starts no work in the tree", async () => {
|
|
1032
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
1033
|
+
const child = await makeWorkflow(parentOf(grandchild.id));
|
|
1034
|
+
const root = await makeWorkflow(parentOf(child.id));
|
|
1035
|
+
const rootId = await startWorkflowExecution(root, {}, registry);
|
|
1036
|
+
const childRun = await runOf(child.id, "waiting");
|
|
1037
|
+
const grandchildRun = await runOf(grandchild.id, "waiting");
|
|
1038
|
+
const [waitStep] = await getWorkflowRunStepsByRunId(grandchildRun.id);
|
|
1039
|
+
const wait = await getWaitStateByStepId(waitStep!.id);
|
|
1040
|
+
await cancelWorkflowRun(rootId);
|
|
1041
|
+
const tree = [rootId, childRun.id, grandchildRun.id];
|
|
1042
|
+
const stepCount = async () =>
|
|
1043
|
+
(
|
|
1044
|
+
await getDbClient().get<{ n: number }>(
|
|
1045
|
+
"SELECT COUNT(*) AS n FROM workflow_run_steps WHERE runId IN (?, ?, ?)",
|
|
1046
|
+
tree,
|
|
1047
|
+
)
|
|
1048
|
+
)?.n;
|
|
1049
|
+
const before = await stepCount();
|
|
1050
|
+
|
|
1051
|
+
await recoverIncompleteRuns(registry);
|
|
1052
|
+
await resumeWaitState(wait!.id, "fired", undefined, registry);
|
|
1053
|
+
await recoverIncompleteRuns(registry);
|
|
1054
|
+
|
|
1055
|
+
expect(await stepCount()).toBe(before);
|
|
1056
|
+
for (const runId of tree) expect(await status(runId)).toBe("cancelled");
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// ─── Lineage guard ───────────────────────────────────────────
|
|
1061
|
+
|
|
1062
|
+
describe("run lineage guard", () => {
|
|
1063
|
+
test("a live run under a cancelled ancestor starts no step, child or task, and a repeat cancel ends it", async () => {
|
|
1064
|
+
// State an older release left behind: the root was cancelled alone.
|
|
1065
|
+
const grandchild = await makeWorkflow(waitingDef);
|
|
1066
|
+
const wf = await makeWorkflow(parentOf(grandchild.id));
|
|
1067
|
+
const rootId = await linkedRun({ workflowId: wf.id, status: "cancelled" });
|
|
1068
|
+
const rootStep = await linkedStep(rootId, {
|
|
1069
|
+
nodeId: "child0",
|
|
1070
|
+
nodeType: "sub-workflow",
|
|
1071
|
+
status: "cancelled",
|
|
1072
|
+
});
|
|
1073
|
+
const childId = await linkedRun({
|
|
1074
|
+
workflowId: wf.id,
|
|
1075
|
+
status: "running",
|
|
1076
|
+
parentStepId: rootStep,
|
|
1077
|
+
});
|
|
1078
|
+
await db.updateWorkflowRun(childId, { context: {} });
|
|
1079
|
+
const childStep = await linkedStep(childId, {
|
|
1080
|
+
nodeId: "child0",
|
|
1081
|
+
nodeType: "sub-workflow",
|
|
1082
|
+
status: "running",
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
expect(await getRunLineageBlock(childId)).toBe(`Ancestor workflow run ${rootId} is cancelled`);
|
|
1086
|
+
await expect(
|
|
1087
|
+
startWorkflowExecution(grandchild, {}, registry, {
|
|
1088
|
+
parentStepId: childStep,
|
|
1089
|
+
parentStepAttempt: 0,
|
|
1090
|
+
}),
|
|
1091
|
+
).rejects.toThrow("no longer running");
|
|
1092
|
+
await expect(
|
|
1093
|
+
createTaskExtended("late", {
|
|
1094
|
+
source: "workflow",
|
|
1095
|
+
workflowRunId: childId,
|
|
1096
|
+
workflowRunStepId: childStep,
|
|
1097
|
+
}),
|
|
1098
|
+
).rejects.toBeInstanceOf(WorkflowStepNotExecutableError);
|
|
1099
|
+
|
|
1100
|
+
// Recovery walks the live child: its step is orphaned, but no new step
|
|
1101
|
+
// starts and the run is not finalized from its steps either.
|
|
1102
|
+
await recoverIncompleteRuns(registry);
|
|
1103
|
+
expect(await getWorkflowRunStepsByRunId(childId)).toHaveLength(1);
|
|
1104
|
+
expect(await runsOf(grandchild.id)).toHaveLength(0);
|
|
1105
|
+
expect(await status(childId)).toBe("running");
|
|
1106
|
+
|
|
1107
|
+
await cancelWorkflowRun(rootId);
|
|
1108
|
+
expect(await status(childId)).toBe("cancelled");
|
|
1109
|
+
expect((await getWorkflowRunStep(childStep))?.status).toBe("cancelled");
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("a child whose parent step is gone gets no permission to run", async () => {
|
|
1113
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
1114
|
+
const childId = await linkedRun({
|
|
1115
|
+
workflowId: wf.id,
|
|
1116
|
+
status: "running",
|
|
1117
|
+
parentStepId: crypto.randomUUID(),
|
|
1118
|
+
});
|
|
1119
|
+
expect(await getRunLineageBlock(childId)).toContain("is missing");
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
test("a child of a live or ended parent is not blocked, so normal retries still run", async () => {
|
|
1123
|
+
const wf = await makeWorkflow({ nodes: [] });
|
|
1124
|
+
const rootId = await linkedRun({ workflowId: wf.id, status: "completed" });
|
|
1125
|
+
const step = await linkedStep(rootId, {
|
|
1126
|
+
nodeId: "child",
|
|
1127
|
+
nodeType: "sub-workflow",
|
|
1128
|
+
status: "completed",
|
|
1129
|
+
});
|
|
1130
|
+
const childId = await linkedRun({ workflowId: wf.id, status: "running", parentStepId: step });
|
|
1131
|
+
expect(await getRunLineageBlock(childId)).toBeNull();
|
|
1132
|
+
expect(await getRunLineageBlock(rootId)).toBeNull();
|
|
1133
|
+
|
|
1134
|
+
// A failing child of a live parent retries through the poller as before.
|
|
1135
|
+
const flaky = await makeWorkflow({
|
|
1136
|
+
nodes: [
|
|
1137
|
+
{
|
|
1138
|
+
id: "flaky",
|
|
1139
|
+
type: "property-match",
|
|
1140
|
+
config: { conditions: [] },
|
|
1141
|
+
retry: { maxRetries: 1, strategy: "static", baseDelayMs: 0, maxDelayMs: 0 },
|
|
1142
|
+
},
|
|
1143
|
+
],
|
|
1144
|
+
});
|
|
1145
|
+
const parent = await makeWorkflow(parentOf(flaky.id));
|
|
1146
|
+
const parentId = await startWorkflowExecution(parent, {}, registry);
|
|
1147
|
+
const [flakyRun] = await waitFor(
|
|
1148
|
+
() => runsOf(flaky.id),
|
|
1149
|
+
(runs) => runs.length === 1,
|
|
1150
|
+
);
|
|
1151
|
+
startRetryPoller(registry, 10);
|
|
1152
|
+
const [flakyStep] = await waitFor(
|
|
1153
|
+
() => getWorkflowRunStepsByRunId(flakyRun!.id),
|
|
1154
|
+
(steps) =>
|
|
1155
|
+
steps.length === 1 &&
|
|
1156
|
+
steps[0]!.status === "failed" &&
|
|
1157
|
+
steps[0]!.retryCount === 1 &&
|
|
1158
|
+
steps[0]!.nextRetryAt == null,
|
|
1159
|
+
);
|
|
1160
|
+
stopRetryPoller();
|
|
1161
|
+
expect(flakyStep!.error).toBeDefined();
|
|
1162
|
+
expect(
|
|
1163
|
+
await waitFor(
|
|
1164
|
+
() => status(parentId),
|
|
1165
|
+
(s) => s === "failed",
|
|
1166
|
+
),
|
|
1167
|
+
).toBe("failed");
|
|
1168
|
+
});
|
|
1169
|
+
});
|